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:
@@ -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/
|
||||
|
||||
@@ -296,18 +296,26 @@ own sigil, colours, and roster width.
|
||||
|
||||
### Window layout
|
||||
|
||||
The chat, roster (clergy), and sandbox-terminal panes are resizable and can be
|
||||
fullscreened — live, with no restart. Resizing the terminal also re-syncs the
|
||||
shared PTY grid so everyone in the room sees the same dimensions.
|
||||
The chat, roster (clergy), sandbox-terminal, and message-input panes are
|
||||
resizable and can be fullscreened — live, with no restart. Resizing the terminal
|
||||
also re-syncs the shared PTY grid so everyone in the room sees the same
|
||||
dimensions.
|
||||
|
||||
- **Fullscreen** — `F4` cycles the sandbox terminal fullscreen → chat
|
||||
fullscreen → back to the split. The 3-line input bar always stays visible, so
|
||||
fullscreen → back to the split. The input bar always stays visible, so
|
||||
`F4` always brings you back.
|
||||
- **Resize a pane** — click a pane (or press `F5` to cycle the selection:
|
||||
terminal → chat → roster). The selected pane gets a bold accent border and an
|
||||
`✎` marker in its title. Then:
|
||||
- `↑` / `↓` grow / shrink the **terminal** or **chat** pane's height share
|
||||
- `←` / `→` narrow / widen the **roster** column (down to hidden)
|
||||
chat → terminal → roster → input). The selected pane gets a bold accent border
|
||||
and an `✎` marker in its title. The chat/terminal/roster panes form a binary
|
||||
split tree, so **every pane resizes on both axes** wherever a divider bounds it:
|
||||
- `↑` / `↓` grow / shrink the selected pane's **height**. With a sandbox up,
|
||||
chat trades against the terminal directly below it. With no sandbox, chat and
|
||||
the clergy instead **borrow from the message bar** — `↑` grows the chat box
|
||||
(shrinks the input), `↓` does the reverse — so vertical resize always moves
|
||||
something. The **input bar** itself is also selectable and grows on `↑/↓`.
|
||||
- `←` / `→` grow / shrink the selected pane's **width** (the chat/terminal
|
||||
column trades with the roster, down to hidden). Resizing the terminal's
|
||||
width now re-syncs the shared PTY too, not just its height.
|
||||
- `Esc` or `Enter` finishes editing.
|
||||
- **Presets** — save the current arrangement and recall it later:
|
||||
|
||||
|
||||
+91
-68
@@ -1,6 +1,13 @@
|
||||
# Spec: BSP pane layout (resize every pane in both dimensions)
|
||||
|
||||
Status: **draft / in progress** · Branch: `feat/bsp-pane-layout`
|
||||
Status: **implemented** · Branch: `feat/bsp-pane-layout`
|
||||
|
||||
> Implementation note: the shipped tree uses two **typed** splits
|
||||
> (`VSplit`/`HSplit`) rather than a single uniform `Split{axis, ratio}`. This
|
||||
> keeps the roster a fixed-cell column (not a percentage) and lets the type
|
||||
> system encode "chat-over-terminal" vs "column-beside-roster" directly. The
|
||||
> sections below describe the original uniform model; the typed shape is called
|
||||
> out inline where they differ.
|
||||
|
||||
## Motivation
|
||||
|
||||
@@ -34,130 +41,146 @@ stays first-class and we take no new dependency.
|
||||
Replace the two scalars with a binary tree. Leaves are the three *semantic*
|
||||
panes; internal nodes are splits with an axis + ratio.
|
||||
|
||||
Original uniform sketch:
|
||||
|
||||
```rust
|
||||
pub enum PaneKind { Chat, Roster, Terminal }
|
||||
|
||||
pub enum Axis { Horizontal, Vertical } // H: side-by-side (|), V: stacked (—)
|
||||
|
||||
pub enum Node {
|
||||
Leaf(PaneKind),
|
||||
Split { axis: Axis, ratio: u16, a: Box<Node>, b: Box<Node> },
|
||||
// `ratio` = percent of the split given to `a` (10..=90).
|
||||
}
|
||||
|
||||
pub struct Layout {
|
||||
root: Node,
|
||||
zoom: Zoom, // unchanged: Normal | Term | Chat (F4 fullscreen)
|
||||
roster_seed: u16, // theme-provided default roster width, for rebuilds
|
||||
}
|
||||
```
|
||||
|
||||
**As shipped** (`hh/src/layout.rs`) — typed splits, reusing `app::Pane` as the
|
||||
leaf type so it can be (de)serialized into presets:
|
||||
|
||||
```rust
|
||||
pub enum Node {
|
||||
Leaf(Pane), // Pane = Chat|Roster|Terminal
|
||||
VSplit { top_pct: u16, top: Box<Node>, bottom: Box<Node> }, // chat over terminal, by %
|
||||
HSplit { right_cells: u16, left: Box<Node>, right: Box<Node> },// column | roster, by cells
|
||||
}
|
||||
|
||||
pub struct Layout { root: Node, pub zoom: Zoom } // Zoom: Normal | Term | Chat
|
||||
```
|
||||
|
||||
The roster width lives as `HSplit.right_cells` (0 = hidden); there's no separate
|
||||
`roster_seed` — the active theme seeds it once via `Layout::set_roster_width`.
|
||||
|
||||
### Default arrangement (reproduces today's look)
|
||||
|
||||
With a sandbox present:
|
||||
With a sandbox present (typed shape as shipped):
|
||||
|
||||
```
|
||||
Split(H, ratio=roster_split, // left column | roster (right, full height)
|
||||
a = Split(V, ratio=chat_pct, // chat (top) / terminal (bottom)
|
||||
a = Leaf(Chat),
|
||||
b = Leaf(Terminal)),
|
||||
b = Leaf(Roster))
|
||||
HSplit(right_cells=22, // left column | roster (right, full height)
|
||||
left = VSplit(top_pct=45, // chat (top) / terminal (bottom)
|
||||
top = Leaf(Chat),
|
||||
bottom = Leaf(Terminal)),
|
||||
right = Leaf(Roster))
|
||||
```
|
||||
|
||||
Without a sandbox the Terminal leaf is absent, so the tree collapses to:
|
||||
|
||||
```
|
||||
Split(H, ratio=roster_split, a = Leaf(Chat), b = Leaf(Roster))
|
||||
```
|
||||
|
||||
The tree is rebuilt (preserving ratios where possible) when the Terminal pane
|
||||
appears (`/sbx launch`) or disappears (`/sbx stop`), and when the roster is
|
||||
hidden/shown. Ratios are stored as percentages and clamped to `[10, 90]`.
|
||||
The tree is **static**; the terminal and roster are *pruned at paint time*
|
||||
rather than rebuilt. `fill()` skips the `VSplit` when no sandbox is up (chat
|
||||
fills the left column) and skips the `HSplit` divider when `right_cells == 0`
|
||||
(roster hidden). So `/sbx launch`/`stop` and hiding the roster need no rebuild —
|
||||
the same tree just paints fewer leaves. `top_pct` is clamped to `[10, 80]`;
|
||||
`right_cells` to `[0, 60]`.
|
||||
|
||||
## Geometry (single source of truth)
|
||||
|
||||
`layout.rs` owns recursive area computation; `ui.rs::body_areas` becomes a thin
|
||||
wrapper so `draw` and `pane_at` keep sharing one geometry.
|
||||
|
||||
As shipped, `regions` just returns the visible leaves (no `Divider` struct yet —
|
||||
that's deferred to phase 5), and takes `has_terminal` so it can prune:
|
||||
|
||||
```rust
|
||||
pub struct Regions { // computed for a given body Rect
|
||||
pub panes: Vec<(PaneKind, Rect)>,// one per visible leaf
|
||||
pub dividers: Vec<Divider>, // for mouse hit-testing (phase 5)
|
||||
}
|
||||
pub fn regions(&self, body: Rect) -> Regions;
|
||||
pub fn rect_of(&self, body: Rect, kind: PaneKind) -> Option<Rect>;
|
||||
pub fn regions(&self, body: Rect, has_terminal: bool) -> Vec<(Pane, Rect)>;
|
||||
pub fn rect_of(&self, body: Rect, pane: Pane, has_terminal: bool) -> Option<Rect>;
|
||||
```
|
||||
|
||||
`Zoom::Term`/`Zoom::Chat` short-circuit `regions` exactly like today (one pane
|
||||
fills the body; the other leaves are omitted).
|
||||
`ui.rs::body_areas` is now a thin wrapper that fans `regions` out into its
|
||||
`{chat, roster, sbx}` rects, so `draw` and `pane_at` share one geometry.
|
||||
`Zoom::Term`/`Zoom::Chat` short-circuit `regions` (one pane fills the body; the
|
||||
other leaves are omitted).
|
||||
|
||||
## Resize semantics (keyboard)
|
||||
|
||||
A pane is selected via `F5` (cycle) or mouse click (`pane_at`). Then arrows
|
||||
resize the **nearest ancestor split on the matching axis**:
|
||||
A pane is selected via `F5` (cycle over `present_panes`) or mouse click
|
||||
(`pane_at`). Then arrows resize the split that bounds it on the matching axis:
|
||||
|
||||
- `↑` / `↓` → nearest ancestor `Split{axis: Vertical}` of the focused leaf;
|
||||
grow/shrink that leaf's side of the split.
|
||||
- `←` / `→` → nearest ancestor `Split{axis: Horizontal}`.
|
||||
- `↑` / `↓` → the `VSplit` (chat/terminal height). Only meaningful with a
|
||||
sandbox up and a non-roster pane selected — otherwise `NoAxisHere`.
|
||||
- `←` / `→` → the `HSplit` (roster `right_cells`). Growing the roster widens it;
|
||||
growing a left-column pane narrows it.
|
||||
|
||||
This makes *every* pane adjustable on both axes wherever such an ancestor
|
||||
exists. If no ancestor on that axis exists (e.g. only Chat+Roster, press `↑`),
|
||||
emit a one-line hint instead of silently doing nothing — fixes consequence (1).
|
||||
This makes *every* pane adjustable on both axes wherever such a split exists. If
|
||||
none does (e.g. only Chat+Roster, press `↑`), emit a one-line hint instead of
|
||||
silently doing nothing — fixes consequence (1).
|
||||
|
||||
```rust
|
||||
pub fn resize_focused(&mut self, pane: PaneKind, dir: Dir, step: u16) -> ResizeOutcome;
|
||||
// ResizeOutcome::Moved | ::NoAxisHere (caller turns the latter into a sys hint)
|
||||
pub fn resize_focused(&mut self, pane: Pane, dir: Dir, has_terminal: bool) -> Resize;
|
||||
// Resize::Moved | ::NoAxisHere (caller turns the latter into a sys hint)
|
||||
```
|
||||
|
||||
The step size is fixed in `layout.rs` (`PCT_STEP = 4`, `CELL_STEP = 2`), not a
|
||||
caller-supplied `step`.
|
||||
|
||||
## PTY integration (the critical wrinkle)
|
||||
|
||||
Today `sbx_dims(term_w, term_h, pty_pct)` derives the PTY grid from the height
|
||||
scalar and assumes **full width**. In the tree model the terminal can be any
|
||||
rect, so the grid must come from the Terminal leaf's actual `Rect`:
|
||||
|
||||
As shipped it returns a concrete grid (falling back to the full body if no
|
||||
Terminal leaf is laid out), rather than an `Option`:
|
||||
|
||||
```rust
|
||||
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> Option<(u16, u16)> {
|
||||
let body = body_rect(term_w, term_h); // minus top bar + input
|
||||
layout.rect_of(body, PaneKind::Terminal)
|
||||
.map(|r| (r.height.saturating_sub(2).max(1), r.width.saturating_sub(2).max(1)))
|
||||
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> (u16, u16) {
|
||||
let body = Rect { x: 0, y: 1, width: term_w, height: term_h.saturating_sub(4) };
|
||||
let r = layout.rect_of(body, Pane::Terminal, true).unwrap_or(body);
|
||||
(r.height.saturating_sub(2).max(1), r.width.saturating_sub(2).max(1))
|
||||
}
|
||||
```
|
||||
|
||||
Every existing `sbx_dims(...)` call site (run loop ~926, command handlers
|
||||
~1865/1971) switches to `sbx_grid`, and `announced_dims = None` still forces a
|
||||
re-sync after any resize — so the shared PTY rebroadcasts new dims to the room
|
||||
exactly as before. **This now also lets terminal *width* changes propagate**,
|
||||
which the old code couldn't express.
|
||||
Every existing `sbx_dims(...)` call site (run loop, the two `/sbx`
|
||||
launch/restore command handlers) switches to `sbx_grid`, and `announced_dims =
|
||||
None` still forces a re-sync after any resize — so the shared PTY rebroadcasts
|
||||
new dims to the room exactly as before. **This now also lets terminal *width*
|
||||
changes propagate** (any divider move sets `announced_dims = None`), which the
|
||||
old height-only code couldn't express.
|
||||
|
||||
## Persistence (presets)
|
||||
|
||||
`/layout save|load|list|rm|reset` keep working; the on-disk TOML now serializes
|
||||
the tree instead of two scalars (`layouts/<slug>.toml`). Old single-scalar
|
||||
preset files are not migrated (presets are throwaway; `reset` rebuilds default).
|
||||
`serde` derives on `Node`/`Axis`/`PaneKind` handle (de)serialization.
|
||||
`serde` derives on `Node`/`Zoom`/`Pane` handle (de)serialization (`Pane` gained
|
||||
`Serialize, Deserialize` in `app.rs` for this).
|
||||
|
||||
## Out of scope (phase 5, optional, follow-up)
|
||||
|
||||
- **Mouse-drag dividers.** `Regions.dividers` + `hit_threshold` are scaffolded
|
||||
now; drag handling (hover highlight, `dragging_split`) lands later. Keyboard
|
||||
resize is the phase-1..4 deliverable.
|
||||
- **Mouse-drag dividers.** Not scaffolded yet — `regions` returns only leaves; a
|
||||
follow-up adds a `Divider`/`hit_threshold` return and drag handling (hover
|
||||
highlight, `dragging_split`). Keyboard resize is the phase-1..4 deliverable.
|
||||
- Arbitrary user-created splits / moving a pane to a new position. The tree
|
||||
supports it, but the UI only exposes the three named panes for now.
|
||||
|
||||
## Work plan
|
||||
|
||||
1. **layout.rs** — tree types, default/rebuild builders, `regions`/`rect_of`,
|
||||
`resize_focused`, clamps, zoom, `describe`, serde, presets. Unit tests
|
||||
(geometry sums to body, resize honors clamps, no-axis case, serde round-trip,
|
||||
sandbox add/remove rebuild).
|
||||
2. **ui.rs** — `body_areas`→tree `regions`; `pane_at` via `regions`; draw each
|
||||
leaf; `edit_decor` marker on the focused pane.
|
||||
3. **app.rs** — `sbx_grid` replaces `sbx_dims`; key handler resizes both axes via
|
||||
`resize_focused` (+ hint on `NoAxisHere`); `cycle_focus` over *present* panes;
|
||||
F4 zoom + `announced_dims` re-sync unchanged.
|
||||
4. **docs** — in-app `/help` LAYOUT cluster + README "Window layout" updated to
|
||||
describe both-axis resize and the no-axis hint.
|
||||
5. *(optional)* mouse-drag dividers.
|
||||
1. ✅ **layout.rs** — typed tree (`VSplit`/`HSplit`), default builder,
|
||||
`regions`/`rect_of`, `resize_focused`, clamps, zoom, `describe`, serde,
|
||||
presets. 11 unit tests (geometry sums to body, resize honors clamps, no-axis
|
||||
case, serde round-trip, terminal/roster prune).
|
||||
2. ✅ **ui.rs** — `body_areas` is now a thin wrapper over `regions`; `pane_at`
|
||||
shares it; `edit_decor` marker on the focused pane.
|
||||
3. ✅ **app.rs** — `sbx_grid` replaces `sbx_dims`; key handler resizes both axes
|
||||
via `resize_focused` (+ hint on `NoAxisHere`); `cycle_focus` over
|
||||
`present_panes`; F4 zoom + `announced_dims` re-sync unchanged.
|
||||
4. ✅ **docs** — in-app `/help` LAYOUT cluster + README "Window layout" updated
|
||||
to describe both-axis resize and the no-axis hint; this spec reconciled.
|
||||
5. *(optional, not done)* mouse-drag dividers.
|
||||
|
||||
## Acceptance
|
||||
|
||||
|
||||
+39
-15
@@ -23,12 +23,16 @@ ASSUME_YES=0
|
||||
CHECK_ONLY=0
|
||||
DO_INSTALL=0
|
||||
PLAN_ONLY=0
|
||||
STDIN_PASS=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
--install) DO_INSTALL=1 ;;
|
||||
--plan|--dry-run) PLAN_ONLY=1; DO_INSTALL=1 ;;
|
||||
# A sudo password is waiting on stdin (the hack-house TUI feeds it). Use
|
||||
# `sudo -S` so escalation reads that, never the controlling tty.
|
||||
--stdin-pass) STDIN_PASS=1 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -37,6 +41,18 @@ done
|
||||
daemon_up() { docker info >/dev/null 2>&1; }
|
||||
have_docker() { command -v docker >/dev/null 2>&1; }
|
||||
|
||||
# How to escalate. Three cases:
|
||||
# * --stdin-pass: a password is waiting on stdin (the hack-house TUI prompted
|
||||
# for it). Use `sudo -S -p ''` so sudo reads it from stdin with no prompt —
|
||||
# never the controlling tty (a raw-mode TUI would corrupt a tty prompt). The
|
||||
# first sudo caches the credential; the rest authenticate from cache.
|
||||
# * --yes alone: non-interactive `sudo -n` — fails fast if creds aren't cached
|
||||
# rather than hanging on a tty prompt the TUI can't host.
|
||||
# * interactive shell: plain `sudo` (a real terminal can prompt normally).
|
||||
SUDO="sudo"
|
||||
[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n"
|
||||
[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''"
|
||||
|
||||
# ── Work out how to install Docker on this platform ──────────────────────────
|
||||
# Emits the ordered list of commands (one per line) to PLAN_LINES; empty if we
|
||||
# don't know how. Uses Docker's official repo so packages are GPG-verified and
|
||||
@@ -54,28 +70,28 @@ build_install_plan() {
|
||||
*debian*|*ubuntu*)
|
||||
local repo="ubuntu"; [[ "$id" == "debian" ]] && repo="debian"
|
||||
PLAN_LINES=$(cat <<EOF
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates curl
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/$repo/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$repo \$(. /etc/os-release && echo \${VERSION_CODENAME}) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y ca-certificates curl
|
||||
$SUDO install -m 0755 -d /etc/apt/keyrings
|
||||
$SUDO curl -fsSL https://download.docker.com/linux/$repo/gpg -o /etc/apt/keyrings/docker.asc
|
||||
$SUDO chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$repo \$(. /etc/os-release && echo \${VERSION_CODENAME}) stable" | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
EOF
|
||||
)
|
||||
;;
|
||||
*fedora*|*rhel*|*centos*)
|
||||
local repo="fedora"; case " $id $id_like " in *rhel*|*centos*) repo="centos";; esac
|
||||
PLAN_LINES=$(cat <<EOF
|
||||
sudo dnf -y install dnf-plugins-core
|
||||
sudo dnf config-manager --add-repo https://download.docker.com/linux/$repo/docker-ce.repo
|
||||
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
$SUDO dnf -y install dnf-plugins-core
|
||||
$SUDO dnf config-manager --add-repo https://download.docker.com/linux/$repo/docker-ce.repo
|
||||
$SUDO dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
EOF
|
||||
)
|
||||
;;
|
||||
*arch*)
|
||||
PLAN_LINES="sudo pacman -S --noconfirm docker"
|
||||
PLAN_LINES="$SUDO pacman -S --noconfirm docker"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -137,7 +153,15 @@ start_cmd=""
|
||||
need_sudo=0
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
# Docker Desktop on Linux runs the engine inside a per-user VM, started
|
||||
# by the *user* unit `docker-desktop.service` — there is no root
|
||||
# `docker.service`, and no sudo is needed. Detect it first so we don't
|
||||
# `sudo systemctl start docker` and fail with "Unit docker.service could
|
||||
# not be found" (which is exactly what the classic-engine path does here).
|
||||
if command -v systemctl >/dev/null 2>&1 \
|
||||
&& systemctl --user cat docker-desktop.service >/dev/null 2>&1; then
|
||||
start_cmd="systemctl --user start docker-desktop"; need_sudo=0
|
||||
elif command -v systemctl >/dev/null 2>&1; then
|
||||
start_cmd="systemctl start docker"; need_sudo=1
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
start_cmd="service docker start"; need_sudo=1
|
||||
@@ -153,7 +177,7 @@ if [[ -z "$start_cmd" ]]; then
|
||||
echo "✖ don't know how to start the docker daemon here — start it manually" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $need_sudo -eq 1 ]] && start_cmd="sudo $start_cmd"
|
||||
[[ $need_sudo -eq 1 ]] && start_cmd="$SUDO $start_cmd"
|
||||
|
||||
# Confirmation (skipped with --yes).
|
||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
@@ -166,7 +190,7 @@ if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
fi
|
||||
|
||||
echo "starting docker daemon: $start_cmd" >&2
|
||||
eval "$start_cmd" || { echo "✖ failed to start docker daemon (sudo password needed? run it in a terminal)" >&2; exit 1; }
|
||||
eval "$start_cmd" || { echo "✖ failed to start docker daemon — needs sudo. Run 'sudo -v' in a terminal first (caches your password), then retry" >&2; exit 1; }
|
||||
|
||||
# Wait for it to accept connections (Desktop / a fresh VM can take a while).
|
||||
for _ in $(seq 1 60); do
|
||||
|
||||
+251
-90
@@ -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
@@ -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
|
||||
/// 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<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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user