feat(layout): input-bar + chat/clergy resize, masked sudo-for-docker, edit-mode unlock

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>
This commit is contained in:
leetcrypt
2026-06-07 16:10:27 -07:00
parent 2ae4adfde4
commit eec1714735
8 changed files with 1115 additions and 359 deletions
+3
View File
@@ -21,6 +21,9 @@ secured_console_chat.egg-info/
*.log
err.log
# Sandbox save/load snapshots (large runtime tarballs, not source)
/hh/hh-snapshots/
# Out-of-tree experiments (not part of hack-house)
/experiments/
/headroom/
+16 -8
View File
@@ -296,18 +296,26 @@ own sigil, colours, and roster width.
### Window layout
The chat, roster (clergy), and sandbox-terminal panes are resizable and can be
fullscreened — live, with no restart. Resizing the terminal also re-syncs the
shared PTY grid so everyone in the room sees the same dimensions.
The chat, roster (clergy), sandbox-terminal, and message-input panes are
resizable and can be fullscreened — live, with no restart. Resizing the terminal
also re-syncs the shared PTY grid so everyone in the room sees the same
dimensions.
- **Fullscreen** — `F4` cycles the sandbox terminal fullscreen → chat
fullscreen → back to the split. The 3-line input bar always stays visible, so
fullscreen → back to the split. The input bar always stays visible, so
`F4` always brings you back.
- **Resize a pane** — click a pane (or press `F5` to cycle the selection:
terminal → chat → roster). The selected pane gets a bold accent border and an
`✎` marker in its title. Then:
- `↑` / `↓` grow / shrink the **terminal** or **chat** pane's height share
- `` / `` narrow / widen the **roster** column (down to hidden)
chat → terminal → roster → input). The selected pane gets a bold accent border
and an `✎` marker in its title. The chat/terminal/roster panes form a binary
split tree, so **every pane resizes on both axes** wherever a divider bounds it:
- `` / `` grow / shrink the selected pane's **height**. With a sandbox up,
chat trades against the terminal directly below it. With no sandbox, chat and
the clergy instead **borrow from the message bar**`↑` grows the chat box
(shrinks the input), `↓` does the reverse — so vertical resize always moves
something. The **input bar** itself is also selectable and grows on `↑/↓`.
- `←` / `→` grow / shrink the selected pane's **width** (the chat/terminal
column trades with the roster, down to hidden). Resizing the terminal's
width now re-syncs the shared PTY too, not just its height.
- `Esc` or `Enter` finishes editing.
- **Presets** — save the current arrangement and recall it later:
+91 -68
View File
@@ -1,6 +1,13 @@
# Spec: BSP pane layout (resize every pane in both dimensions)
Status: **draft / in progress** · Branch: `feat/bsp-pane-layout`
Status: **implemented** · Branch: `feat/bsp-pane-layout`
> Implementation note: the shipped tree uses two **typed** splits
> (`VSplit`/`HSplit`) rather than a single uniform `Split{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
@@ -34,130 +41,146 @@ stays first-class and we take no new dependency.
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:
```rust
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> },
// `ratio` = percent of the split given to `a` (10..=90).
}
pub struct Layout {
root: Node,
zoom: Zoom, // unchanged: Normal | Term | Chat (F4 fullscreen)
roster_seed: u16, // theme-provided default roster width, for rebuilds
}
```
**As shipped** (`hh/src/layout.rs`) — typed splits, reusing `app::Pane` as the
leaf type so it can be (de)serialized into presets:
```rust
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:
With a sandbox present (typed shape as shipped):
```
Split(H, ratio=roster_split, // left column | roster (right, full height)
a = Split(V, ratio=chat_pct, // chat (top) / terminal (bottom)
a = Leaf(Chat),
b = Leaf(Terminal)),
b = Leaf(Roster))
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))
```
Without a sandbox the Terminal leaf is absent, so the tree collapses to:
```
Split(H, ratio=roster_split, a = Leaf(Chat), b = Leaf(Roster))
```
The tree is rebuilt (preserving ratios where possible) when the Terminal pane
appears (`/sbx launch`) or disappears (`/sbx stop`), and when the roster is
hidden/shown. Ratios are stored as percentages and clamped to `[10, 90]`.
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:
```rust
pub struct Regions { // computed for a given body Rect
pub panes: Vec<(PaneKind, Rect)>,// one per visible leaf
pub dividers: Vec<Divider>, // for mouse hit-testing (phase 5)
}
pub fn regions(&self, body: Rect) -> Regions;
pub fn rect_of(&self, body: Rect, kind: PaneKind) -> Option<Rect>;
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>;
```
`Zoom::Term`/`Zoom::Chat` short-circuit `regions` exactly like today (one pane
fills the body; the other leaves are omitted).
`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) or mouse click (`pane_at`). Then arrows
resize the **nearest ancestor split on the matching axis**:
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:
- `↑` / `↓`nearest ancestor `Split{axis: Vertical}` of the focused leaf;
grow/shrink that leaf's side of the split.
- `←` / `→`nearest ancestor `Split{axis: Horizontal}`.
- `↑` / `↓`the `VSplit` (chat/terminal height). Only meaningful with a
sandbox up and a non-roster pane selected — otherwise `NoAxisHere`.
- `←` / `→`the `HSplit` (roster `right_cells`). Growing the roster widens it;
growing a left-column pane narrows it.
This makes *every* pane adjustable on both axes wherever such an ancestor
exists. If no ancestor on that axis exists (e.g. only Chat+Roster, press `↑`),
emit a one-line hint instead of silently doing nothing — fixes consequence (1).
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).
```rust
pub fn resize_focused(&mut self, pane: PaneKind, dir: Dir, step: u16) -> ResizeOutcome;
// ResizeOutcome::Moved | ::NoAxisHere (caller turns the latter into a sys hint)
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`:
```rust
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> Option<(u16, u16)> {
let body = body_rect(term_w, term_h); // minus top bar + input
layout.rect_of(body, PaneKind::Terminal)
.map(|r| (r.height.saturating_sub(2).max(1), r.width.saturating_sub(2).max(1)))
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 ~926, command handlers
~1865/1971) 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**,
which the old code couldn't express.
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`/`Axis`/`PaneKind` handle (de)serialization.
`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.** `Regions.dividers` + `hit_threshold` are scaffolded
now; drag handling (hover highlight, `dragging_split`) lands later. Keyboard
resize is the phase-1..4 deliverable.
- **Mouse-drag dividers.** Not scaffolded yet — `regions` returns only leaves; a
follow-up adds a `Divider`/`hit_threshold` return 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
1. **layout.rs** — tree types, default/rebuild builders, `regions`/`rect_of`,
`resize_focused`, clamps, zoom, `describe`, serde, presets. Unit tests
(geometry sums to body, resize honors clamps, no-axis case, serde round-trip,
sandbox add/remove rebuild).
2. **ui.rs**`body_areas`→tree `regions`; `pane_at` via `regions`; draw each
leaf; `edit_decor` marker on the focused pane.
3. **app.rs**`sbx_grid` replaces `sbx_dims`; key handler resizes both axes via
`resize_focused` (+ hint on `NoAxisHere`); `cycle_focus` over *present* panes;
F4 zoom + `announced_dims` re-sync unchanged.
4. **docs** — in-app `/help` LAYOUT cluster + README "Window layout" updated to
describe both-axis resize and the no-axis hint.
5. *(optional)* mouse-drag dividers.
1. **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).
2. **ui.rs**`body_areas` is now a thin wrapper over `regions`; `pane_at`
shares it; `edit_decor` marker on the focused pane.
3. **app.rs**`sbx_grid` replaces `sbx_dims`; key handler resizes both axes
via `resize_focused` (+ hint on `NoAxisHere`); `cycle_focus` over
`present_panes`; F4 zoom + `announced_dims` re-sync unchanged.
4. **docs** — in-app `/help` LAYOUT cluster + README "Window layout" updated
to describe both-axis resize and the no-axis hint; this spec reconciled.
5. *(optional, not done)* mouse-drag dividers.
## Acceptance
+39 -15
View File
@@ -23,12 +23,16 @@ ASSUME_YES=0
CHECK_ONLY=0
DO_INSTALL=0
PLAN_ONLY=0
STDIN_PASS=0
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
--check) CHECK_ONLY=1 ;;
--install) DO_INSTALL=1 ;;
--plan|--dry-run) PLAN_ONLY=1; DO_INSTALL=1 ;;
# A sudo password is waiting on stdin (the hack-house TUI feeds it). Use
# `sudo -S` so escalation reads that, never the controlling tty.
--stdin-pass) STDIN_PASS=1 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
esac
@@ -37,6 +41,18 @@ done
daemon_up() { docker info >/dev/null 2>&1; }
have_docker() { command -v docker >/dev/null 2>&1; }
# How to escalate. Three cases:
# * --stdin-pass: a password is waiting on stdin (the hack-house TUI prompted
# for it). Use `sudo -S -p ''` so sudo reads it from stdin with no prompt —
# never the controlling tty (a raw-mode TUI would corrupt a tty prompt). The
# first sudo caches the credential; the rest authenticate from cache.
# * --yes alone: non-interactive `sudo -n` — fails fast if creds aren't cached
# rather than hanging on a tty prompt the TUI can't host.
# * interactive shell: plain `sudo` (a real terminal can prompt normally).
SUDO="sudo"
[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n"
[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''"
# ── Work out how to install Docker on this platform ──────────────────────────
# Emits the ordered list of commands (one per line) to PLAN_LINES; empty if we
# don't know how. Uses Docker's official repo so packages are GPG-verified and
@@ -54,28 +70,28 @@ build_install_plan() {
*debian*|*ubuntu*)
local repo="ubuntu"; [[ "$id" == "debian" ]] && repo="debian"
PLAN_LINES=$(cat <<EOF
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$repo/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$repo \$(. /etc/os-release && echo \${VERSION_CODENAME}) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
$SUDO apt-get update
$SUDO apt-get install -y ca-certificates curl
$SUDO install -m 0755 -d /etc/apt/keyrings
$SUDO curl -fsSL https://download.docker.com/linux/$repo/gpg -o /etc/apt/keyrings/docker.asc
$SUDO chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$repo \$(. /etc/os-release && echo \${VERSION_CODENAME}) stable" | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
$SUDO apt-get update
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
EOF
)
;;
*fedora*|*rhel*|*centos*)
local repo="fedora"; case " $id $id_like " in *rhel*|*centos*) repo="centos";; esac
PLAN_LINES=$(cat <<EOF
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/$repo/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
$SUDO dnf -y install dnf-plugins-core
$SUDO dnf config-manager --add-repo https://download.docker.com/linux/$repo/docker-ce.repo
$SUDO dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
EOF
)
;;
*arch*)
PLAN_LINES="sudo pacman -S --noconfirm docker"
PLAN_LINES="$SUDO pacman -S --noconfirm docker"
;;
esac
}
@@ -137,7 +153,15 @@ start_cmd=""
need_sudo=0
case "$(uname -s)" in
Linux)
if command -v systemctl >/dev/null 2>&1; then
# Docker Desktop on Linux runs the engine inside a per-user VM, started
# by the *user* unit `docker-desktop.service` — there is no root
# `docker.service`, and no sudo is needed. Detect it first so we don't
# `sudo systemctl start docker` and fail with "Unit docker.service could
# not be found" (which is exactly what the classic-engine path does here).
if command -v systemctl >/dev/null 2>&1 \
&& systemctl --user cat docker-desktop.service >/dev/null 2>&1; then
start_cmd="systemctl --user start docker-desktop"; need_sudo=0
elif command -v systemctl >/dev/null 2>&1; then
start_cmd="systemctl start docker"; need_sudo=1
elif command -v service >/dev/null 2>&1; then
start_cmd="service docker start"; need_sudo=1
@@ -153,7 +177,7 @@ if [[ -z "$start_cmd" ]]; then
echo "✖ don't know how to start the docker daemon here — start it manually" >&2
exit 1
fi
[[ $need_sudo -eq 1 ]] && start_cmd="sudo $start_cmd"
[[ $need_sudo -eq 1 ]] && start_cmd="$SUDO $start_cmd"
# Confirmation (skipped with --yes).
if [[ $ASSUME_YES -ne 1 ]]; then
@@ -166,7 +190,7 @@ if [[ $ASSUME_YES -ne 1 ]]; then
fi
echo "starting docker daemon: $start_cmd" >&2
eval "$start_cmd" || { echo "✖ failed to start docker daemon (sudo password needed? run it in a terminal)" >&2; exit 1; }
eval "$start_cmd" || { echo "✖ failed to start docker daemon — needs sudo. Run 'sudo -v' in a terminal first (caches your password), then retry" >&2; exit 1; }
# Wait for it to accept connections (Desktop / a fresh VM can take a while).
for _ in $(seq 1 60); do
+251 -90
View File
@@ -1,7 +1,7 @@
//! TUI application state, network event model, and the async run loop.
use crate::ft;
use crate::layout::Layout;
use crate::layout::{Dir, Layout, Resize};
use crate::net::{self, Session};
use crate::sbx;
use crate::theme::Theme;
@@ -20,6 +20,7 @@ use crossterm::terminal::{
use futures_util::{SinkExt, StreamExt};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -162,11 +163,37 @@ pub struct VboxPicker {
/// A resizable region of the window, for interactive layout editing. Selected by
/// clicking it (or cycling with F5); once selected, arrow keys resize it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Pane {
Chat,
Roster,
Terminal,
/// The message/compose box at the bottom. Unlike the other three it lives
/// outside the BSP body tree (it's the frame's fixed bottom row), so its only
/// adjustable axis is height — grow it to read a long/wrapped message.
Input,
}
/// Launch parameters captured when `/sbx launch` needs root but sudo isn't
/// cached. Held aside while the masked password modal collects the secret; the
/// launch fires (with the password) once it's submitted.
pub struct PendingSudoLaunch {
pub backend: sbx::Backend,
pub image: String,
pub members: Vec<String>,
pub rows: u16,
pub cols: u16,
pub start_daemon: bool,
pub install_first: bool,
}
/// A local, masked sudo-password prompt. The typed password lives ONLY here —
/// it never enters `App::input` (chat), a `ChatLine`, the PTY stream, or any
/// outbound frame — until it's handed to `sudo -S` for the pending launch.
/// Deliberately no `Debug` derive so the secret can't be logged by accident.
pub struct SudoPrompt {
pub password: String,
pub pending: PendingSudoLaunch,
}
pub struct App {
@@ -228,6 +255,10 @@ pub struct App {
/// (click it or cycle with F5), or None when not editing. When set, arrow
/// keys resize this pane instead of scrolling.
pub focused_pane: Option<Pane>,
/// Active local sudo-password prompt (None unless `/sbx launch` needs root
/// and creds aren't cached). While `Some`, keystrokes feed this masked
/// buffer instead of chat — the secret never leaves the client.
pub sudo_prompt: Option<SudoPrompt>,
}
impl App {
@@ -263,20 +294,32 @@ impl App {
agent_name: None,
layout: Layout::default(),
focused_pane: None,
sudo_prompt: None,
}
}
/// Cycle the interactive-edit selection: Terminal → Chat → Roster → off.
/// Bound to F5; clicking a pane jumps straight to it instead. The Terminal
/// step is skipped when no sandbox is up (nothing to resize there).
/// Length of the in-progress sudo password (for the masked modal to render
/// `•`s), or `None` when no prompt is open. Exposes only the length — never
/// the secret itself — so `ui` can draw it without touching the bytes.
pub fn sudo_prompt_len(&self) -> Option<usize> {
self.sudo_prompt.as_ref().map(|p| p.password.chars().count())
}
/// Cycle the interactive-edit selection: Chat → Terminal → Roster → Input →
/// off. Bound to F5; clicking a pane jumps straight to it instead. The
/// Terminal step is skipped when no sandbox is up (nothing to resize there);
/// the Input bar is always a stop (height-only).
fn cycle_focus(&mut self) {
let has_term = self.sandbox.is_some();
// Cycle over the panes the layout is actually painting (chat → terminal →
// roster → input), then off. Skips the terminal with no sandbox and the
// roster when it's hidden, so every stop is something you can resize.
let panes = self.layout.present_panes(self.sandbox.is_some());
self.focused_pane = match self.focused_pane {
None if has_term => Some(Pane::Terminal),
None => Some(Pane::Chat),
Some(Pane::Terminal) => Some(Pane::Chat),
Some(Pane::Chat) => Some(Pane::Roster),
Some(Pane::Roster) => None,
None => panes.first().copied(),
Some(cur) => match panes.iter().position(|p| *p == cur) {
Some(i) => panes.get(i + 1).copied(),
None => panes.first().copied(),
},
};
}
@@ -502,6 +545,7 @@ fn pane_label(p: Pane) -> &'static str {
Pane::Chat => "chat",
Pane::Roster => "roster",
Pane::Terminal => "terminal",
Pane::Input => "input",
}
}
@@ -514,17 +558,28 @@ fn once_or_none(names: Vec<String>) -> String {
}
}
/// PTY grid (rows, cols) for the sandbox terminal. `pty_pct` is the terminal's
/// share of the body height (see `layout::Layout`); it must match the split
/// `ui::draw` paints, so they stay in lock-step via `app.layout`. Width is the
/// full body width — the terminal pane always spans the frame, only its height
/// changes with the split.
fn sbx_dims(term_w: u16, term_h: u16, pty_pct: u16) -> (u16, u16) {
let body_h = term_h.saturating_sub(4);
let sbx_h = (body_h as u32 * pty_pct as u32 / 100) as u16;
/// PTY grid (rows, cols) for the sandbox terminal, derived from the Terminal
/// leaf's actual rectangle in the layout tree — so it tracks **both** axes (the
/// old height-only `pty_pct` couldn't express width changes). Matches the split
/// `ui::draw` paints, keeping the local PTY and the room in lock-step. Subtracts
/// the pane border (2 each way) and floors at 1. Falls back to the full body
/// when no Terminal leaf is laid out (e.g. transiently before the tree rebuilds).
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> (u16, u16) {
use ratatui::layout::Rect;
// Mirror ui::draw's frame split: 1-line top bar, body, then the input bar
// (now a resizable height, not a fixed 3 lines).
let body = Rect {
x: 0,
y: 1,
width: term_w,
height: term_h.saturating_sub(1 + layout.input_height()),
};
let r = layout
.rect_of(body, Pane::Terminal, true)
.unwrap_or(body);
(
sbx_h.saturating_sub(2).max(1),
term_w.saturating_sub(2).max(1),
r.height.saturating_sub(2).max(1),
r.width.saturating_sub(2).max(1),
)
}
@@ -901,7 +956,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
// Seed the roster width from the active vestment so a `--theme` with a wider
// roster is honoured; from here on the live layout owns it (so /theme swaps
// don't stomp a width the user has set with /layout).
app.layout.roster_width = theme.roster_width;
app.layout.set_roster_width(theme.roster_width);
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(50));
@@ -923,7 +978,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
if broker.is_some() {
if let Ok(sz) = term.size() {
let dims = sbx_dims(sz.width, sz.height, app.layout.effective_pty_pct());
let dims = sbx_grid(sz.width, sz.height, &app.layout);
if announced_dims != Some(dims) {
announced_dims = Some(dims);
if let Some(sb) = &broker {
@@ -953,7 +1008,58 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
{
break Ok(());
}
if k.modifiers.contains(KeyModifiers::CONTROL)
// Masked sudo-password prompt (Option C). When open it
// swallows EVERY keystroke so the password can never leak
// into chat, the PTY, or an outbound frame — it lives only
// in `app.sudo_prompt.password` until it's moved into the
// launch task and fed to `sudo -S` over stdin.
if app.sudo_prompt.is_some() {
match k.code {
KeyCode::Enter => {
// SAFETY of secrecy: `take()` moves the buffer
// out; it's never cloned into any logged/visible
// surface. The password is handed to spawn_launch
// and dropped there after stdin is fed.
let SudoPrompt { password, pending } =
app.sudo_prompt.take().unwrap();
if password.is_empty() {
app.sys("⛧ cancelled — empty password");
} else {
launching = true;
app.sys("🔒 authenticating + launching…");
spawn_launch(
pending.backend,
pending.image,
app.me.clone(),
pending.members,
pending.rows,
pending.cols,
pending.start_daemon,
pending.install_first,
Some(password),
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
}
KeyCode::Esc => {
app.sudo_prompt = None;
app.sys("⛧ sudo cancelled — launch aborted");
}
KeyCode::Backspace => {
if let Some(p) = app.sudo_prompt.as_mut() {
p.password.pop();
}
}
KeyCode::Char(c) => {
if let Some(p) = app.sudo_prompt.as_mut() {
p.password.push(c);
}
}
_ => {}
}
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('x'))
&& !app.driving
{
@@ -1132,32 +1238,48 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
// before the driving branch but is gated on !driving so the
// shell still gets its keys while you hold the PTY.
let pane = app.focused_pane.unwrap();
match (pane, k.code) {
(_, KeyCode::Esc) | (_, KeyCode::Enter) => {
let has_term = app.sandbox.is_some();
let dir = match k.code {
KeyCode::Up => Some(Dir::Up),
KeyCode::Down => Some(Dir::Down),
KeyCode::Left => Some(Dir::Left),
KeyCode::Right => Some(Dir::Right),
_ => None,
};
match k.code {
KeyCode::Esc | KeyCode::Enter => {
app.focused_pane = None;
app.sys(format!("{}", app.layout.describe()));
}
// Terminal taller / chat shorter (and the mirror image
// when the chat pane is the one selected).
(Pane::Terminal, KeyCode::Up) | (Pane::Chat, KeyCode::Down) => {
app.layout.grow_pty(3);
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
_ if dir.is_some() => {
// Every pane is resizable on both axes wherever a
// divider bounds it; resize_focused moves the right
// one (or reports NoAxisHere so we can hint).
match app.layout.resize_focused(pane, dir.unwrap(), has_term) {
Resize::Moved => {
// Any divider move can change the terminal's
// rect (height *or* width now), so force a
// PTY re-sync to rebroadcast the new grid.
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
}
Resize::NoAxisHere => {
app.sys(
"no divider on that axis here — F5 to the input bar to grow the compose box, or launch a sandbox for terminal height",
);
}
}
}
(Pane::Terminal, KeyCode::Down) | (Pane::Chat, KeyCode::Up) => {
app.layout.shrink_pty(3);
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Left) => {
app.layout.roster_width =
app.layout.roster_width.saturating_sub(2);
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Right) => {
app.layout.roster_width =
(app.layout.roster_width + 2).min(Layout::MAX_ROSTER);
app.sys(format!("{}", app.layout.describe()));
// Typing a printable char is an escape hatch: drop out
// of layout-edit mode and feed the key to the compose box.
// Without this, a stray click into edit mode silently
// swallows every keystroke and feels like a freeze.
KeyCode::Char(c)
if !k.modifiers.contains(KeyModifiers::CONTROL)
&& !k.modifiers.contains(KeyModifiers::ALT) =>
{
app.focused_pane = None;
app.input.push(c);
}
_ => {} // ignore other keys while editing
}
@@ -1253,16 +1375,20 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
if !app.driving && !app.show_help && app.vbox_picker.is_none() =>
{
if let Ok(sz) = term.size() {
if let Some(p) =
ui::pane_at(sz.width, sz.height, &app, m.column, m.row)
{
app.focused_pane = Some(p);
app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
));
} else {
app.focused_pane = None;
match ui::pane_at(sz.width, sz.height, &app, m.column, m.row) {
// Clicking the compose box must NOT enter layout-edit
// mode — that's where you type, and locking it on a
// click is exactly the "stuck, can't type" footgun.
Some(p) if p != Pane::Input => {
app.focused_pane = Some(p);
app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
));
}
_ => {
app.focused_pane = None;
}
}
}
}
@@ -1677,9 +1803,9 @@ fn handle_command(
));
}
"reset" | "default" => {
let roster = app.layout.roster_width;
let roster = app.layout.roster_width();
app.layout = Layout::default();
app.layout.roster_width = roster; // keep your roster choice on reset
app.layout.set_roster_width(roster); // keep your roster choice on reset
resized = true;
app.sys(format!("⛧ layout reset — {}", app.layout.describe()));
}
@@ -1861,35 +1987,64 @@ fn handle_command(
// it off-thread before provisioning (a fresh Docker
// install also leaves its daemon up).
let install_first = !installed;
// Installing Docker needs root; booting its daemon needs
// root too — *except* Docker Desktop, whose engine starts
// via a per-user systemd unit with no sudo at all.
let needs_sudo = install_first
|| (start_daemon
&& backend == sbx::Backend::Docker
&& !sbx::docker_daemon_up()
&& !sbx::docker_desktop());
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let (rows, cols) = sbx_grid(sz.0, sz.1, &app.layout);
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
if install_first {
app.sys(format!(
"installing {} (needs sudo)… then summoning the sandbox",
backend.label()
));
if needs_sudo && !sbx::sudo_ready() {
// Root is needed but sudo isn't cached. sudo can't prompt
// on the tty from inside the raw-mode TUI, so capture the
// password in a masked, local-only modal and feed it to
// `sudo -S`. The launch fires on submit (see the run loop).
app.sudo_prompt = Some(SudoPrompt {
password: String::new(),
pending: PendingSudoLaunch {
backend,
image,
members,
rows,
cols,
start_daemon,
install_first,
},
});
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
} else {
app.sys(format!(
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
backend.label()
));
*launching = true;
if install_first {
app.sys(format!(
"installing {} (needs sudo)… then summoning the sandbox",
backend.label()
));
} else {
app.sys(format!(
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
backend.label()
));
}
spawn_launch(
backend,
image,
app.me.clone(),
members,
rows,
cols,
start_daemon,
install_first,
None,
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
spawn_launch(
backend,
image,
app.me.clone(),
members,
rows,
cols,
start_daemon,
install_first,
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
}
}
@@ -1968,7 +2123,7 @@ fn handle_command(
// (stopped, preserved) instance and re-attaches its shell.
let label = label.to_string();
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
let (rows, cols) = sbx_grid(sz.0, sz.1, &app.layout);
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
@@ -1994,7 +2149,7 @@ fn handle_command(
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}")));
spawn_launch(
sbx::Backend::Docker, image, owner, members, rows,
cols, false, false, pty, btx, atx,
cols, false, false, None, pty, btx, atx,
);
}
sbx::SnapKind::Multipass => {
@@ -2009,7 +2164,7 @@ fn handle_command(
spawn_launch(
sbx::Backend::Multipass,
sbx::Backend::Multipass.default_image().to_string(),
owner, members, rows, cols, false, false, pty, btx, atx,
owner, members, rows, cols, false, false, None, pty, btx, atx,
);
}
Ok(Err(e)) => {
@@ -2646,6 +2801,10 @@ fn spawn_launch(
cols: u16,
start_daemon: bool,
install_first: bool,
// The sudo password captured by the in-TUI masked prompt, if escalation was
// needed. Local-only: it is fed to `sudo -S` via stdin inside the blocking
// install/prepare steps and never enters chat, the PTY, or any outbound frame.
password: Option<String>,
pty_tx: UnboundedSender<Vec<u8>>,
broker_tx: UnboundedSender<BrokerMsg>,
app_tx: UnboundedSender<Net>,
@@ -2655,25 +2814,27 @@ fn spawn_launch(
// opted in. Runs before provisioning; failure aborts the launch (and
// clears the *launching guard via BrokerMsg::Failed).
if install_first {
let pw = password.clone();
let res = tokio::task::spawn_blocking(move || match backend {
sbx::Backend::Docker => sbx::ensure_docker_install(),
sbx::Backend::Docker => sbx::ensure_docker_install(pw),
sbx::Backend::Multipass => sbx::ensure_multipass_install(),
_ => Ok(()),
})
.await;
if let Err(e) = res.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("install failed: {e}")));
let _ = app_tx.send(Net::Err(format!("install failed: {e:#}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
}
let name = SBX_NAME.to_string();
let prep = {
let (n, img) = (name.clone(), image.clone());
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon)).await
let (n, img, pw) = (name.clone(), image.clone(), password.clone());
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon, pw))
.await
};
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e}")));
let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e:#}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
+497 -90
View File
@@ -1,24 +1,36 @@
//! Window layout ("cell plan"): how the chat, roster and sandbox-terminal panes
//! divide the screen, plus a fullscreen ("zoom") mode for the terminal or chat.
//! Window layout ("cell plan"): a small binary space-partition (BSP) pane tree
//! describing how the chat, roster and sandbox-terminal panes divide the screen,
//! plus a fullscreen ("zoom") mode for the terminal or chat.
//!
//! The split is a single source of truth shared by `ui::draw` (what's painted)
//! and `app::sbx_dims` (the PTY grid we resize the real shell to). Because the
//! run loop re-syncs the PTY every tick, changing these values is all it takes
//! to live-resize the terminal and broadcast the new dims to the room.
//! The tree is the single source of truth shared by `ui::draw` (what's painted),
//! `ui::pane_at` (mouse hit-testing) and `app::sbx_grid` (the PTY grid we resize
//! the real shell to). Because the run loop re-syncs the PTY every tick, changing
//! a divider is all it takes to live-resize the terminal and broadcast the new
//! dims to the room.
//!
//! Two typed splits cover the three semantic panes:
//! * `VSplit` — chat (top) over terminal (bottom), by **percentage** of height.
//! * `HSplit` — left column beside the roster, the roster a **fixed cell** column
//! on the right (`0` cells = hidden), matching the stable narrow-column look.
//! Nesting them gives every pane both-axis adjustability: the left column (chat +
//! terminal) shares the `HSplit` width, and chat/terminal share the `VSplit`
//! height. The roster is a full-height column, so only its width is adjustable.
//!
//! Named arrangements can be saved to `layouts/<slug>.toml` and re-applied later
//! with `/layout load <name>`, mirroring how `theme.rs` persists vestments.
use crate::app::Pane;
use anyhow::Context;
use ratatui::layout::{Constraint, Direction, Layout as RLayout, Rect};
use serde::{Deserialize, Serialize};
/// Where saved layout presets live (mirrors `theme::THEMES_DIR`).
pub const LAYOUTS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/layouts");
/// Fullscreen state. `Normal` honours the `pty_pct` split; `Term` gives the
/// whole body to the sandbox terminal (chat + roster hidden); `Chat` hides the
/// terminal so chat + roster fill the body. The 3-line input bar always stays
/// visible, so you can always type `/layout normal` or press F4 to get back.
/// Fullscreen state. `Normal` honours the tree; `Term` gives the whole body to
/// the sandbox terminal (chat + roster hidden); `Chat` hides the terminal so chat
/// + roster fill the body. The 3-line input bar always stays visible, so F4 (or
/// `/layout reset`) always brings you back.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Zoom {
Normal,
@@ -32,50 +44,122 @@ impl Default for Zoom {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
/// Which divider a resize key acts on: vertical (↑/↓ height) or horizontal
/// (←/→ width). `grow` is true for ↑/→ (enlarge the focused pane), false for ↓/←.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
fn grows(self) -> bool {
matches!(self, Dir::Up | Dir::Right)
}
}
/// Outcome of a resize attempt, so the caller can hint when an axis isn't
/// adjustable for the focused pane (e.g. height with no sandbox up).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resize {
/// A divider moved.
Moved,
/// No divider on that axis bounds the focused pane (caller shows a hint).
NoAxisHere,
}
/// A node in the layout tree: a leaf pane, or one of the two typed splits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Node {
Leaf(Pane),
/// Chat (top) over terminal (bottom); `top_pct` is the top's share of height.
VSplit {
top_pct: u16,
top: Box<Node>,
bottom: Box<Node>,
},
/// Left column beside the roster; `right_cells` is the roster column width
/// (`0` hides it). The left side takes the remaining width.
HSplit {
right_cells: u16,
left: Box<Node>,
right: Box<Node>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Layout {
/// Sandbox terminal's share of the body height, as a percentage (clamped
/// 2090 so neither chat nor terminal can collapse to nothing).
pub pty_pct: u16,
/// Roster column width in cells. `0` hides the roster entirely.
pub roster_width: u16,
/// Fullscreen state (not all presets care; defaults to `Normal`).
root: Node,
pub zoom: Zoom,
/// Height (in rows, borders included) of the bottom message/compose box. The
/// input bar lives outside `root` (it's the frame's fixed bottom row), so it
/// can't be a tree leaf — this scalar is its one adjustable axis. `↑/↓` while
/// the Input pane is focused grows/shrinks it so a long, wrapped message is
/// readable even with no sandbox up.
input_height: u16,
}
impl Default for Layout {
fn default() -> Self {
Self {
pty_pct: 55,
roster_width: 22,
root: default_root(),
zoom: Zoom::Normal,
input_height: DEFAULT_INPUT,
}
}
}
/// The default arrangement: roster as a right-hand column, with chat over the
/// sandbox terminal in the left column (reproduces the historical look).
fn default_root() -> Node {
Node::HSplit {
right_cells: DEFAULT_ROSTER,
left: Box::new(Node::VSplit {
top_pct: DEFAULT_TOP_PCT,
top: Box::new(Node::Leaf(Pane::Chat)),
bottom: Box::new(Node::Leaf(Pane::Terminal)),
}),
right: Box::new(Node::Leaf(Pane::Roster)),
}
}
/// Default chat share of the left-column height (terminal gets the rest).
const DEFAULT_TOP_PCT: u16 = 45;
/// Default roster column width in cells.
const DEFAULT_ROSTER: u16 = 22;
/// Default input-bar height (1 content line + 2 border rows) — the historical look.
const DEFAULT_INPUT: u16 = 3;
impl Layout {
/// Lower/upper bounds for the terminal's height share.
pub const MIN_PCT: u16 = 20;
pub const MAX_PCT: u16 = 90;
/// Lower/upper bounds for the chat (top) height share — neither chat nor
/// terminal may collapse to nothing.
pub const MIN_TOP: u16 = 10;
pub const MAX_TOP: u16 = 80;
/// Upper bound on roster width (keeps it from eating the whole chat column).
pub const MAX_ROSTER: u16 = 60;
/// Bounds on the input-bar height (rows, borders included). Min keeps the one
/// content line + its borders; max stops it swallowing the whole body.
pub const MIN_INPUT: u16 = 3;
pub const MAX_INPUT: u16 = 16;
/// Cells/percent/rows moved per arrow press.
const PCT_STEP: u16 = 4;
const CELL_STEP: u16 = 2;
const ROW_STEP: u16 = 1;
/// Re-clamp every field into its valid range. Call after any mutation that
/// came from user input so out-of-range values can never reach the layout.
pub fn clamp(&mut self) {
self.pty_pct = self.pty_pct.clamp(Self::MIN_PCT, Self::MAX_PCT);
self.roster_width = self.roster_width.min(Self::MAX_ROSTER);
/// Seed the roster column width (called once from the active theme).
pub fn set_roster_width(&mut self, cells: u16) {
if let Node::HSplit { right_cells, .. } = &mut self.root {
*right_cells = cells.min(Self::MAX_ROSTER);
}
}
/// The terminal's effective height share once zoom is taken into account:
/// `Term` fills the body (100%); `Normal`/`Chat` use the stored split (the
/// terminal is simply not painted under `Chat`, so its size stays steady).
pub fn effective_pty_pct(&self) -> u16 {
match self.zoom {
Zoom::Term => 100,
_ => self.pty_pct,
}
/// Re-clamp every divider into its valid range.
pub fn clamp(&mut self) {
clamp_node(&mut self.root);
self.input_height = self.input_height.clamp(Self::MIN_INPUT, Self::MAX_INPUT);
}
/// Cycle the fullscreen state: Normal → Term → Chat → Normal. Bound to F4.
@@ -87,17 +171,112 @@ impl Layout {
};
}
/// Grow the terminal pane by `step` percent (drops out of any zoom first so
/// the change is visible). Stays within [MIN_PCT, MAX_PCT].
pub fn grow_pty(&mut self, step: u16) {
self.zoom = Zoom::Normal;
self.pty_pct = (self.pty_pct + step).min(Self::MAX_PCT);
/// Carve `body` into the visible pane rectangles, honouring the current zoom
/// and whether a sandbox terminal exists. The single source of truth for both
/// painting and hit-testing.
pub fn regions(&self, body: Rect, has_terminal: bool) -> Vec<(Pane, Rect)> {
let mut out = Vec::new();
match self.zoom {
// Terminal fullscreen (only if one exists; otherwise fall through).
Zoom::Term if has_terminal => out.push((Pane::Terminal, body)),
// Chat fullscreen: paint the tree with the terminal hidden so chat +
// roster fill the body.
Zoom::Chat => fill(&self.root, body, false, &mut out),
_ => fill(&self.root, body, has_terminal, &mut out),
}
out
}
/// Shrink the terminal pane by `step` percent (counterpart of `grow_pty`).
pub fn shrink_pty(&mut self, step: u16) {
self.zoom = Zoom::Normal;
self.pty_pct = self.pty_pct.saturating_sub(step).max(Self::MIN_PCT);
/// The rectangle a given pane occupies in `body`, if visible.
pub fn rect_of(&self, body: Rect, pane: Pane, has_terminal: bool) -> Option<Rect> {
self.regions(body, has_terminal)
.into_iter()
.find(|(p, _)| *p == pane)
.map(|(_, r)| r)
}
/// Panes currently visible, in a stable cycle order (chat → terminal → roster
/// → input) for F5 focus cycling. The input bar is always present, so it's
/// always a stop — that's the one resize you can do with no sandbox up.
pub fn present_panes(&self, has_terminal: bool) -> Vec<Pane> {
let mut v = vec![Pane::Chat];
if has_terminal {
v.push(Pane::Terminal);
}
if self.roster_width() > 0 {
v.push(Pane::Roster);
}
v.push(Pane::Input);
v
}
/// Current input-bar height (rows, borders included).
pub fn input_height(&self) -> u16 {
self.input_height
}
/// Resize the divider bounding `pane` on the axis of `dir`. `grow` (↑/→)
/// enlarges the focused pane. Vertical resize always does *something* now:
/// chat trades against the terminal when a sandbox is up, and otherwise chat
/// and the roster drag the body↔input divider — so the chat box visibly
/// grows/shrinks on ↑/↓ even with no sandbox (space is borrowed from / given
/// back to the message bar below). Returns `NoAxisHere` only when truly no
/// divider applies.
pub fn resize_focused(&mut self, pane: Pane, dir: Dir, has_terminal: bool) -> Resize {
// The input bar is outside the tree: its only axis is height, adjusted
// directly here (↑ grows, ↓ shrinks). ←/→ have nothing to act on.
if pane == Pane::Input {
return match dir {
Dir::Up | Dir::Down => {
self.input_height = step_rows(self.input_height, dir.grows());
Resize::Moved
}
Dir::Left | Dir::Right => Resize::NoAxisHere,
};
}
match dir {
Dir::Up | Dir::Down => {
// Chat and terminal share the VSplit when a sandbox is up — the
// chat box trades its height against the terminal directly below.
if has_terminal && (pane == Pane::Chat || pane == Pane::Terminal) {
if let Some((top_pct, _, _)) = find_vsplit(&mut self.root) {
// chat is the top; grow chat → bigger top share.
let grow_top = (pane == Pane::Chat) == dir.grows();
*top_pct = step_pct(*top_pct, grow_top);
return Resize::Moved;
}
}
// Otherwise the pane's downward neighbour is the message bar. Chat
// (no sandbox) and the roster drag the body↔input divider: growing
// the pane (↑) grows the body — i.e. the chat box — and shrinks the
// input bar; ↓ does the reverse. This is what makes the chat box
// fluctuate on ↑/↓ for both chat and clergy.
if pane == Pane::Chat || pane == Pane::Roster {
self.input_height = step_rows(self.input_height, !dir.grows());
return Resize::Moved;
}
Resize::NoAxisHere
}
Dir::Left | Dir::Right => {
if let Some(right_cells) = find_hsplit(&mut self.root) {
// roster is the right column; growing the roster widens it,
// growing a left-column pane narrows it.
let grow_right = (pane == Pane::Roster) == dir.grows();
*right_cells = step_cells(*right_cells, grow_right);
Resize::Moved
} else {
Resize::NoAxisHere
}
}
}
}
/// Current roster column width in cells (0 when hidden).
pub fn roster_width(&self) -> u16 {
match &self.root {
Node::HSplit { right_cells, .. } => *right_cells,
_ => 0,
}
}
/// A one-line description of the current arrangement (for `/layout`).
@@ -107,30 +286,28 @@ impl Layout {
Zoom::Term => "terminal-fullscreen",
Zoom::Chat => "chat-fullscreen",
};
let roster = if self.roster_width == 0 {
"off".to_string()
} else {
self.roster_width.to_string()
let top = find_top_pct(&self.root).unwrap_or(DEFAULT_TOP_PCT);
let roster = match self.roster_width() {
0 => "off".to_string(),
n => format!("{n} cells"),
};
format!(
"terminal {}% · chat {}% · roster {} · {}",
self.pty_pct,
100 - self.pty_pct,
"chat {}% · terminal {}% · roster {} · input {} rows · {}",
top,
100 - top,
roster,
self.input_height,
zoom
)
}
/// Persist this arrangement to `layouts/<slug>.toml` so it can be re-applied
/// later with `/layout load <slug>`. Returns the slug actually written.
/// Persist this arrangement to `layouts/<slug>.toml`. Returns the slug written.
pub fn save(&self, name: &str) -> anyhow::Result<String> {
let slug = slugify(name);
anyhow::ensure!(!slug.is_empty(), "give the layout a name (letters/digits)");
std::fs::create_dir_all(LAYOUTS_DIR)
.with_context(|| format!("creating {LAYOUTS_DIR}"))?;
std::fs::create_dir_all(LAYOUTS_DIR).with_context(|| format!("creating {LAYOUTS_DIR}"))?;
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
let body = toml::to_string_pretty(self)
.with_context(|| format!("serialize layout '{slug}'"))?;
let body = toml::to_string_pretty(self).with_context(|| format!("serialize layout '{slug}'"))?;
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
Ok(slug)
}
@@ -139,8 +316,7 @@ impl Layout {
pub fn by_name(name: &str) -> anyhow::Result<Self> {
let slug = slugify(name);
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
let s = std::fs::read_to_string(&path)
.with_context(|| format!("layout '{slug}' ({path})"))?;
let s = std::fs::read_to_string(&path).with_context(|| format!("layout '{slug}' ({path})"))?;
let mut l: Layout = toml::from_str(&s)?;
l.clamp();
Ok(l)
@@ -172,6 +348,114 @@ impl Layout {
}
}
impl Default for Node {
fn default() -> Self {
default_root()
}
}
/// Recursively paint `node` into `rect`, pruning the terminal when absent and the
/// roster when its column is 0-width (the divider vanishes, the sibling fills).
fn fill(node: &Node, rect: Rect, has_terminal: bool, out: &mut Vec<(Pane, Rect)>) {
match node {
Node::Leaf(p) => out.push((*p, rect)),
Node::VSplit { top_pct, top, bottom } => {
if !has_terminal {
fill(top, rect, has_terminal, out); // terminal pruned → chat fills
return;
}
let parts = RLayout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(*top_pct), Constraint::Percentage(100 - *top_pct)])
.split(rect);
fill(top, parts[0], has_terminal, out);
fill(bottom, parts[1], has_terminal, out);
}
Node::HSplit { right_cells, left, right } => {
if *right_cells == 0 {
fill(left, rect, has_terminal, out); // roster hidden → left fills
return;
}
let parts = RLayout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Length(*right_cells)])
.split(rect);
fill(left, parts[0], has_terminal, out);
fill(right, parts[1], has_terminal, out);
}
}
}
/// Nearest VSplit's `top_pct` (mutable) on the path to a leaf. With our default
/// tree there is exactly one.
fn find_vsplit(node: &mut Node) -> Option<(&mut u16, &mut Box<Node>, &mut Box<Node>)> {
match node {
Node::Leaf(_) => None,
Node::VSplit { top_pct, top, bottom } => Some((top_pct, top, bottom)),
Node::HSplit { left, right, .. } => find_vsplit(left).or_else(|| find_vsplit(right)),
}
}
/// Nearest HSplit's `right_cells` (mutable) on the path to a leaf.
fn find_hsplit(node: &mut Node) -> Option<&mut u16> {
match node {
Node::Leaf(_) => None,
Node::HSplit { right_cells, .. } => Some(right_cells),
Node::VSplit { top, bottom, .. } => find_hsplit(top).or_else(|| find_hsplit(bottom)),
}
}
fn find_top_pct(node: &Node) -> Option<u16> {
match node {
Node::Leaf(_) => None,
Node::VSplit { top_pct, .. } => Some(*top_pct),
Node::HSplit { left, right, .. } => find_top_pct(left).or_else(|| find_top_pct(right)),
}
}
fn step_pct(pct: u16, grow: bool) -> u16 {
let next = if grow {
pct + Layout::PCT_STEP
} else {
pct.saturating_sub(Layout::PCT_STEP)
};
next.clamp(Layout::MIN_TOP, Layout::MAX_TOP)
}
fn step_cells(cells: u16, grow: bool) -> u16 {
let next = if grow {
cells + Layout::CELL_STEP
} else {
cells.saturating_sub(Layout::CELL_STEP)
};
next.min(Layout::MAX_ROSTER)
}
fn step_rows(rows: u16, grow: bool) -> u16 {
let next = if grow {
rows + Layout::ROW_STEP
} else {
rows.saturating_sub(Layout::ROW_STEP)
};
next.clamp(Layout::MIN_INPUT, Layout::MAX_INPUT)
}
fn clamp_node(node: &mut Node) {
match node {
Node::Leaf(_) => {}
Node::VSplit { top_pct, top, bottom } => {
*top_pct = (*top_pct).clamp(Layout::MIN_TOP, Layout::MAX_TOP);
clamp_node(top);
clamp_node(bottom);
}
Node::HSplit { right_cells, left, right } => {
*right_cells = (*right_cells).min(Layout::MAX_ROSTER);
clamp_node(left);
clamp_node(right);
}
}
}
/// Reduce a free-form name to a safe lowercase `<slug>` (ascii letters/digits,
/// any other run folded to a single '-'), matching theme.rs's convention.
fn slugify(name: &str) -> String {
@@ -195,45 +479,168 @@ fn slugify(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn clamp_bounds_pct_and_roster() {
let mut l = Layout {
pty_pct: 999,
roster_width: 999,
zoom: Zoom::Normal,
};
l.clamp();
assert_eq!(l.pty_pct, Layout::MAX_PCT);
assert_eq!(l.roster_width, Layout::MAX_ROSTER);
let mut low = Layout {
pty_pct: 1,
roster_width: 0,
zoom: Zoom::Normal,
};
low.clamp();
assert_eq!(low.pty_pct, Layout::MIN_PCT);
assert_eq!(low.roster_width, 0); // 0 is valid (hidden)
fn body() -> Rect {
Rect::new(0, 0, 100, 40)
}
#[test]
fn effective_pct_full_under_term_zoom() {
fn regions_cover_body_with_sandbox() {
let l = Layout::default();
let regs = l.regions(body(), true);
// chat, terminal, roster all present.
assert_eq!(regs.len(), 3);
assert!(regs.iter().any(|(p, _)| *p == Pane::Chat));
assert!(regs.iter().any(|(p, _)| *p == Pane::Terminal));
assert!(regs.iter().any(|(p, _)| *p == Pane::Roster));
// total painted area equals the body (no gaps/overlap in a binary split).
let area: u32 = regs.iter().map(|(_, r)| r.area() as u32).sum();
assert_eq!(area, body().area() as u32);
}
#[test]
fn no_sandbox_prunes_terminal() {
let l = Layout::default();
let regs = l.regions(body(), false);
assert!(!regs.iter().any(|(p, _)| *p == Pane::Terminal));
assert!(regs.iter().any(|(p, _)| *p == Pane::Chat));
// chat now owns the full left-column height.
let chat = l.rect_of(body(), Pane::Chat, false).unwrap();
assert_eq!(chat.height, body().height);
}
#[test]
fn roster_hidden_at_zero_cells() {
let mut l = Layout::default();
l.set_roster_width(0);
let regs = l.regions(body(), true);
assert!(!regs.iter().any(|(p, _)| *p == Pane::Roster));
// chat column now spans full width.
let chat = l.rect_of(body(), Pane::Chat, true).unwrap();
assert_eq!(chat.width, body().width);
}
#[test]
fn vertical_resize_grows_focused_pane() {
let mut l = Layout::default();
let chat0 = l.rect_of(body(), Pane::Chat, true).unwrap().height;
assert_eq!(l.resize_focused(Pane::Chat, Dir::Up, true), Resize::Moved);
let chat1 = l.rect_of(body(), Pane::Chat, true).unwrap().height;
assert!(chat1 > chat0, "chat grew taller on ↑");
// terminal up grows the terminal (chat shrinks).
assert_eq!(l.resize_focused(Pane::Terminal, Dir::Up, true), Resize::Moved);
let chat2 = l.rect_of(body(), Pane::Chat, true).unwrap().height;
assert!(chat2 < chat1, "chat shrank when terminal grew");
}
#[test]
fn horizontal_resize_changes_roster_width() {
let mut l = Layout::default();
let r0 = l.rect_of(body(), Pane::Roster, true).unwrap().width;
assert_eq!(l.resize_focused(Pane::Roster, Dir::Right, true), Resize::Moved);
let r1 = l.rect_of(body(), Pane::Roster, true).unwrap().width;
assert!(r1 > r0, "roster widened on →");
// focusing chat and pressing → narrows the roster (grows the left column).
assert_eq!(l.resize_focused(Pane::Chat, Dir::Right, true), Resize::Moved);
let r2 = l.rect_of(body(), Pane::Roster, true).unwrap().width;
assert!(r2 < r1, "roster narrowed when chat grew");
}
#[test]
fn vertical_resize_drags_input_when_no_terminal() {
let mut l = Layout::default();
let h0 = l.input_height(); // default = MIN_INPUT
// Chat ↓ with no sandbox shrinks the chat body → grows the input bar.
assert_eq!(l.resize_focused(Pane::Chat, Dir::Down, false), Resize::Moved);
assert!(l.input_height() > h0, "chat ↓ grew the input bar");
let h1 = l.input_height();
// Chat ↑ grows the chat body → shrinks the input bar.
assert_eq!(l.resize_focused(Pane::Chat, Dir::Up, false), Resize::Moved);
assert!(l.input_height() < h1, "chat ↑ shrank the input bar");
// The roster trades the same way (even with a sandbox up): clergy borrows
// height from the chat body via the same divider.
l.resize_focused(Pane::Roster, Dir::Down, true);
let h2 = l.input_height();
assert_eq!(l.resize_focused(Pane::Roster, Dir::Up, true), Resize::Moved);
assert!(l.input_height() < h2, "roster ↑ shrank the input bar (body grew)");
}
#[test]
fn resize_honours_clamps() {
let mut l = Layout::default();
for _ in 0..50 {
l.resize_focused(Pane::Chat, Dir::Up, true);
}
assert_eq!(find_top_pct(&l.root), Some(Layout::MAX_TOP));
for _ in 0..50 {
l.resize_focused(Pane::Chat, Dir::Down, true);
}
assert_eq!(find_top_pct(&l.root), Some(Layout::MIN_TOP));
}
#[test]
fn present_panes_track_visibility() {
let mut l = Layout::default();
assert_eq!(
l.present_panes(true),
vec![Pane::Chat, Pane::Terminal, Pane::Roster, Pane::Input]
);
assert_eq!(
l.present_panes(false),
vec![Pane::Chat, Pane::Roster, Pane::Input]
);
l.set_roster_width(0);
assert_eq!(l.present_panes(false), vec![Pane::Chat, Pane::Input]);
}
#[test]
fn input_resize_grows_and_clamps_without_sandbox() {
let mut l = Layout::default();
let h0 = l.input_height();
// ↑ grows the input bar even with no sandbox (has_terminal = false).
assert_eq!(l.resize_focused(Pane::Input, Dir::Up, false), Resize::Moved);
assert!(l.input_height() > h0, "input grew taller on ↑");
// ←/→ have no axis for the input bar.
assert_eq!(
l.resize_focused(Pane::Input, Dir::Left, false),
Resize::NoAxisHere
);
// Clamp at both ends.
for _ in 0..50 {
l.resize_focused(Pane::Input, Dir::Up, false);
}
assert_eq!(l.input_height(), Layout::MAX_INPUT);
for _ in 0..50 {
l.resize_focused(Pane::Input, Dir::Down, false);
}
assert_eq!(l.input_height(), Layout::MIN_INPUT);
}
#[test]
fn serde_round_trips_the_tree() {
let mut l = Layout::default();
l.resize_focused(Pane::Chat, Dir::Up, true);
l.set_roster_width(30);
let s = toml::to_string_pretty(&l).unwrap();
let back: Layout = toml::from_str(&s).unwrap();
assert_eq!(back, l);
}
#[test]
fn zoom_term_fills_body() {
let mut l = Layout::default();
assert_eq!(l.effective_pty_pct(), 55);
l.zoom = Zoom::Term;
assert_eq!(l.effective_pty_pct(), 100);
l.zoom = Zoom::Chat;
assert_eq!(l.effective_pty_pct(), 55); // chat hidden ≠ resize the pty
let regs = l.regions(body(), true);
assert_eq!(regs.len(), 1);
assert_eq!(regs[0].0, Pane::Terminal);
assert_eq!(regs[0].1, body());
}
#[test]
fn cycle_and_resize_drop_out_of_zoom() {
fn zoom_chat_hides_terminal() {
let mut l = Layout::default();
l.cycle_zoom();
assert_eq!(l.zoom, Zoom::Term);
l.grow_pty(5);
assert_eq!(l.zoom, Zoom::Normal);
assert_eq!(l.pty_pct, 60);
l.shrink_pty(100);
assert_eq!(l.pty_pct, Layout::MIN_PCT);
l.zoom = Zoom::Chat;
let regs = l.regions(body(), true);
assert!(!regs.iter().any(|(p, _)| *p == Pane::Terminal));
assert!(regs.iter().any(|(p, _)| *p == Pane::Chat));
}
}
+86 -21
View File
@@ -49,16 +49,81 @@ pub fn docker_daemon_up() -> bool {
.unwrap_or(false)
}
/// Is this a Docker Desktop (Linux) install? Its engine runs in a per-user VM
/// started by the *user* unit `docker-desktop.service` — there's no root
/// `docker.service`, so starting the daemon needs **no sudo**. Detect it so the
/// launch path doesn't pop a (useless, and on this box failing) sudo prompt.
pub fn docker_desktop() -> bool {
Command::new("systemctl")
.args(["--user", "cat", "docker-desktop.service"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Can we sudo *without* a password prompt right now? (`sudo -n true` succeeds
/// when credentials are cached via a prior `sudo -v`, or NOPASSWD is configured.)
/// The launch paths that need root check this first: a raw-mode TUI can't host
/// sudo's interactive tty prompt, so we must never let sudo block on one.
pub fn sudo_ready() -> bool {
Command::new("sudo")
.args(["-n", "true"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Run `ensure-docker.sh --yes` with the given extra args, optionally feeding a
/// sudo password to the script's `sudo -S` via stdin.
///
/// Secret handling: the password (if any) is written to the child's stdin and
/// then the local buffer is wiped. It only ever travels parent→child stdin; it
/// is NEVER echoed, logged, or surfaced — sudo never prints the password, so the
/// captured stderr (used for error messages) can't contain it. With no password
/// we close stdin and the script uses `sudo -n` (fails fast if creds aren't
/// cached) so it can never block on an interactive tty prompt.
fn run_ensure_docker(extra: &[&str], password: Option<String>) -> Result<(bool, String)> {
let mut cmd = Command::new("bash");
cmd.arg(ENSURE_DOCKER).arg("--yes");
for a in extra {
cmd.arg(a);
}
if password.is_some() {
cmd.arg("--stdin-pass").stdin(Stdio::piped());
} else {
cmd.stdin(Stdio::null());
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().context("running ensure-docker.sh")?;
if let Some(mut pw) = password {
if let Some(mut stdin) = child.stdin.take() {
// Feed the password to the first `sudo -S`; it caches the credential
// so the rest of the plan authenticates without re-reading stdin.
let _ = writeln!(stdin, "{pw}"); // stdin drops here → EOF
}
// Best-effort wipe of our copy of the secret.
unsafe {
for b in pw.as_bytes_mut() {
*b = 0;
}
}
pw.clear();
}
let out = child.wait_with_output().context("ensure-docker.sh")?;
Ok((out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned()))
}
/// Start the Docker daemon via `ensure-docker.sh --yes`, waiting until it's
/// ready. Returns the script's last error line on failure (e.g. needs sudo).
fn start_docker_daemon() -> Result<()> {
let out = Command::new("bash")
.arg(ENSURE_DOCKER)
.arg("--yes")
.output()
.context("running ensure-docker.sh")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
/// ready. With `password`, escalation goes through `sudo -S` (read from stdin);
/// without it the script uses `sudo -n` and fails fast if creds aren't cached.
fn start_docker_daemon(password: Option<String>) -> Result<()> {
let (ok, err) = run_ensure_docker(&[], password)?;
if !ok {
let last = err
.lines()
.last()
@@ -71,16 +136,10 @@ fn start_docker_daemon() -> Result<()> {
/// Install Docker via `ensure-docker.sh --install --yes` (Docker's official,
/// GPG-verified repo), then leave the daemon started. Consent is the caller's
/// job (they passed `install`); the script is idempotent if Docker is present.
/// Returns the script's last error line on failure (e.g. needs sudo).
pub fn ensure_docker_install() -> Result<()> {
let out = Command::new("bash")
.arg(ENSURE_DOCKER)
.arg("--install")
.arg("--yes")
.output()
.context("running ensure-docker.sh --install")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
/// `password` feeds `sudo -S` as above.
pub fn ensure_docker_install(password: Option<String>) -> Result<()> {
let (ok, err) = run_ensure_docker(&["--install"], password)?;
if !ok {
let last = err.lines().last().unwrap_or("could not install Docker");
anyhow::bail!("{last}");
}
@@ -463,7 +522,13 @@ impl Backend {
/// One-time setup before the PTY shell is spawned. Blocking — run off the UI
/// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance
/// that already exists.
pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> Result<()> {
pub fn prepare(
backend: Backend,
name: &str,
image: &str,
start_daemon: bool,
password: Option<String>,
) -> Result<()> {
match backend {
Backend::Local => Ok(()),
Backend::Multipass => {
@@ -504,7 +569,7 @@ pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) ->
// `/sbx launch docker --start`).
if !docker_daemon_up() {
if start_daemon {
start_docker_daemon().context("starting docker daemon")?;
start_docker_daemon(password).context("starting docker daemon")?;
} else {
anyhow::bail!(
"docker daemon is not running — retry with `/sbx launch docker --start`"
+132 -67
View File
@@ -1,7 +1,6 @@
//! ratatui rendering — top bar, chat, roster, input.
use crate::app::{App, ChatLine, Role};
use crate::layout::Zoom;
use crate::theme::Theme;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
@@ -21,7 +20,7 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(app.layout.input_height()),
])
.split(f.area());
@@ -51,6 +50,9 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
if let Some(msg) = &app.error {
draw_error(f, f.area(), theme, msg);
}
if let Some(len) = app.sudo_prompt_len() {
draw_sudo_prompt(f, f.area(), theme, len);
}
}
/// The body's pane rectangles. Any field is `None` when that pane is hidden
@@ -65,40 +67,25 @@ struct BodyAreas {
/// honouring the sandbox presence, `Zoom`, and roster width. This is the single
/// source of truth used by both `draw` (painting) and `pane_at` (hit-testing).
fn body_areas(body: Rect, app: &App) -> BodyAreas {
// Vertical split: chat-column vs sandbox terminal.
let (chat_col, sbx) = if app.sandbox.is_some() {
match app.layout.zoom {
Zoom::Term => (None, Some(body)), // terminal fullscreen
Zoom::Chat => (Some(body), None), // chat fullscreen (terminal hidden)
Zoom::Normal => {
let pty = app.layout.pty_pct;
let split = Layout::vertical([
Constraint::Percentage(100 - pty),
Constraint::Percentage(pty),
])
.split(body);
(Some(split[0]), Some(split[1]))
}
}
} else {
(Some(body), None) // no sandbox → chat owns the whole body
use crate::app::Pane;
let mut out = BodyAreas {
chat: None,
roster: None,
sbx: None,
};
// Horizontal split of the chat column into chat vs roster.
let (chat, roster) = match chat_col {
Some(col) if app.layout.roster_width != 0 => {
let lr = Layout::horizontal([
Constraint::Min(1),
Constraint::Length(app.layout.roster_width),
])
.split(col);
(Some(lr[0]), Some(lr[1]))
// The layout tree is the single source of truth: it honours zoom, sandbox
// presence and roster width, returning one rect per visible pane.
for (pane, rect) in app.layout.regions(body, app.sandbox.is_some()) {
match pane {
Pane::Chat => out.chat = Some(rect),
Pane::Roster => out.roster = Some(rect),
Pane::Terminal => out.sbx = Some(rect),
// The input bar isn't a body region (it's the frame's bottom row);
// `regions` never yields it, so this arm is just for exhaustiveness.
Pane::Input => {}
}
Some(col) => (Some(col), None), // roster hidden
None => (None, None),
};
BodyAreas { chat, roster, sbx }
}
out
}
/// Hit-test a screen cell against the laid-out panes, for click-to-select in
@@ -112,16 +99,19 @@ pub fn pane_at(w: u16, h: u16, app: &App, col: u16, row: u16) -> Option<crate::a
width: w,
height: h,
};
// Mirror draw()'s top-bar / body / input split; only the body is selectable.
// Mirror draw()'s top-bar / body / input split; the body panes and the input
// bar are selectable (the input bar grows its height when focused).
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(app.layout.input_height()),
])
.split(area);
let areas = body_areas(rows[1], app);
let p = Position { x: col, y: row };
if areas.sbx.is_some_and(|r| r.contains(p)) {
if rows[2].contains(p) {
Some(Pane::Input)
} else if areas.sbx.is_some_and(|r| r.contains(p)) {
Some(Pane::Terminal)
} else if areas.roster.is_some_and(|r| r.contains(p)) {
Some(Pane::Roster)
@@ -185,6 +175,43 @@ fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
f.render_widget(popup, rect);
}
/// Masked sudo-password modal (Option C). Renders one bullet per typed char —
/// never the password itself — anchored just above the input box. The buffer it
/// reflects lives in `app.sudo_prompt` and is never sent to chat or the PTY.
fn draw_sudo_prompt(f: &mut Frame, area: Rect, theme: &Theme, len: usize) {
let dots: String = "".repeat(len);
let body = format!("password: {dots}");
let w = area.width.saturating_sub(4).clamp(28, 56);
let h = 3; // one input line + its borders
let x = area.x + (area.width.saturating_sub(w)) / 2;
// Hover just above the input row (bottom of the screen) so it reads as a prompt.
let y = area.y + area.height.saturating_sub(h + 2);
let rect = Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let popup = Paragraph::new(body)
.style(Style::default().fg(theme.title).bg(theme.bg))
.block(
Block::bordered()
.border_style(
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)
.title(Span::styled(
" 🔒 sudo · Enter launch · Esc cancel ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
);
f.render_widget(popup, rect);
}
fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vy = (100u16.saturating_sub(percent_y)) / 2;
let vx = (100u16.saturating_sub(percent_x)) / 2;
@@ -366,13 +393,16 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
kv("F4", "fullscreen the terminal (cycle: terminal → chat → split)"),
kv(
"click a pane · F5",
"select a pane to resize (✎ marks it) — F5 cycles terminal → chat → roster",
"select a pane to resize (✎ marks it) — F5 cycles chat → terminal → roster → input",
),
kv(
"↑ / ↓ (terminal/chat selected)",
"grow / shrink that pane's height share",
"↑ / ↓",
"grow / shrink height — chat ↔ terminal with a sandbox; else chat/clergy borrow from the message bar",
),
kv(
"← / →",
"grow / shrink the selected pane's width (left column ↔ roster)",
),
kv("← / → (roster selected)", "narrow / widen the roster column"),
kv("Esc / Enter", "finish editing the selected pane"),
kv("/layout reset", "restore the default split"),
kv(
@@ -782,29 +812,58 @@ fn ai_thinking_title(app: &App) -> String {
}
fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let input = Paragraph::new(Line::from(vec![
Span::styled("> ", Style::default().fg(theme.accent)),
Span::styled(app.input.as_str(), Style::default().fg(theme.input)),
]))
.block(
Block::bordered()
.border_style(Style::default().fg(if app.pending_offer.is_some() {
theme.accent
use crate::app::Pane;
// Char-wrap "> " + the message at the inner width so a long line flows into
// the (resizable) extra height. We wrap ourselves — rather than ratatui's
// word-wrap — so the cursor lands exactly where the text breaks.
let inner = area.width.saturating_sub(2).max(1) as usize;
let visible = area.height.saturating_sub(2).max(1) as usize;
let prompt = "> ";
let full: Vec<char> = prompt.chars().chain(app.input.chars()).collect();
let mut wrapped: Vec<Line> = Vec::new();
if full.is_empty() {
wrapped.push(Line::from(""));
} else {
for (li, chunk) in full.chunks(inner).enumerate() {
let s: String = chunk.iter().collect();
if li == 0 {
// Split the accented "> " prompt off the first visual line.
let split = prompt.len().min(s.len());
let (pfx, rest) = s.split_at(split);
wrapped.push(Line::from(vec![
Span::styled(pfx.to_string(), Style::default().fg(theme.accent)),
Span::styled(rest.to_string(), Style::default().fg(theme.input)),
]));
} else {
theme.border
}))
wrapped.push(Line::from(Span::styled(s, Style::default().fg(theme.input))));
}
}
}
// Keep the tail (where you're typing) in view when it overflows the box.
let skip = wrapped.len().saturating_sub(visible);
let shown: Vec<Line> = wrapped.into_iter().skip(skip).collect();
// Focused for resize? Accent border + ✎ marker, matching the body panes.
let (decor_style, decor_mark) = edit_decor(app, Pane::Input, theme, theme.border);
let border_style = if app.focused_pane == Some(Pane::Input) {
decor_style
} else if app.pending_offer.is_some() {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.border)
};
let title_text = match &app.pending_offer {
Some(o) => format!(" {} incoming: {} — /accept or /reject ", theme.sigil, o.name),
None if app.driving => format!(" {} DRIVING the shell — Esc to release ", theme.sigil),
None if !app.ai_typing.is_empty() => ai_thinking_title(app),
None => format!("{decor_mark} message · enter send · /drive for shell · ctrl-q quit "),
};
let input = Paragraph::new(shown).block(
Block::bordered()
.border_style(border_style)
.title(Span::styled(
match &app.pending_offer {
Some(o) => format!(
" {} incoming: {} — /accept or /reject ",
theme.sigil, o.name
),
None if app.driving => {
format!(" {} DRIVING the shell — Esc to release ", theme.sigil)
}
None if !app.ai_typing.is_empty() => ai_thinking_title(app),
None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(),
},
title_text,
Style::default().fg(if app.ai_typing.is_empty() {
theme.title
} else {
@@ -814,10 +873,16 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The
);
f.render_widget(input, area);
// Cursor after the "> " prompt + current input.
let cx = area.x + 3 + app.input.chars().count() as u16;
let cy = area.y + 1;
if cx < area.x + area.width.saturating_sub(1) {
f.set_cursor_position(Position::new(cx, cy));
// Cursor sits after the last typed char; its wrapped line/col is exact since
// we wrapped at `inner` ourselves. Hidden if it would land past the box tail.
let end = full.len();
let cline = end / inner;
let ccol = end % inner;
if cline >= skip {
let cx = area.x + 1 + ccol as u16;
let cy = area.y + 1 + (cline - skip) as u16;
if cy < area.y + area.height.saturating_sub(1) {
f.set_cursor_position(Position::new(cx, cy));
}
}
}