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
+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));
}
}
}