diff --git a/.gitignore b/.gitignore index e06a1a0..037ee06 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.MD b/README.MD index b24810f..ec0f068 100644 --- a/README.MD +++ b/README.MD @@ -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: diff --git a/docs/bsp-pane-layout.md b/docs/bsp-pane-layout.md index e7fef48..8f03220 100644 --- a/docs/bsp-pane-layout.md +++ b/docs/bsp-pane-layout.md @@ -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, b: Box }, - // `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, bottom: Box }, // chat over terminal, by % + HSplit { right_cells: u16, left: Box, right: Box },// 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, // for mouse hit-testing (phase 5) -} -pub fn regions(&self, body: Rect) -> Regions; -pub fn rect_of(&self, body: Rect, kind: PaneKind) -> Option; +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; ``` -`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/.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 diff --git a/hh/scripts/ensure-docker.sh b/hh/scripts/ensure-docker.sh index 26b80e6..cd3b1eb 100755 --- a/hh/scripts/ensure-docker.sh +++ b/hh/scripts/ensure-docker.sh @@ -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 </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 </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 diff --git a/hh/src/app.rs b/hh/src/app.rs index 20afba4..a7bc5ab 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -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, + 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, + /// 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, } 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 { + 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 { } } -/// 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 = 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 = 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, pty_tx: UnboundedSender>, broker_tx: UnboundedSender, app_tx: UnboundedSender, @@ -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; } diff --git a/hh/src/layout.rs b/hh/src/layout.rs index fa0c228..700e444 100644 --- a/hh/src/layout.rs +++ b/hh/src/layout.rs @@ -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/.toml` and re-applied later //! with `/layout load `, 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, + bottom: Box, + }, + /// 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, + right: Box, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct Layout { - /// Sandbox terminal's share of the body height, as a percentage (clamped - /// 20–90 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 { + 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 { + 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/.toml` so it can be re-applied - /// later with `/layout load `. Returns the slug actually written. + /// Persist this arrangement to `layouts/.toml`. Returns the slug written. pub fn save(&self, name: &str) -> anyhow::Result { 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 { 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, &mut Box)> { + 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 { + 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 `` (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)); } } diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index bf92b03..0fd7d74 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -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) -> 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) -> 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) -> 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, +) -> 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`" diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 3a0c2b3..37f84f1 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -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 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 { 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 = prompt.chars().chain(app.input.chars()).collect(); + + let mut wrapped: Vec = 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 = 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)); + } } }