feat(hh): /pw command, RAM-only direnv autostart, robust lets-hack; coven→clergy
- add /pw (alias /password): reveal this room's password locally (never broadcast); surfaced in the F1 help overlay and the join hint - direnv-autostart/: cd-to-launch a single real-user session via direnv; password is minted in memory at launch (never written to disk, matching the RAM-only model) and scoped to the child process. setup.sh installs direnv, hooks bash/zsh, and `direnv allow`s the dir - lets-hack.sh: boot a FRESH server by default (replacing any live one) with a --reuse opt-out; add -h/--help/-help; guard against killing the tmux session you're attached to; switch-client into the coven when run inside tmux - rename coven→clergy across rust/python/scripts; tests/test_coven.py→test_clergy.py - snapshots in-progress hack-house client work (sandbox, themes, net, ui) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+184
-31
@@ -8,7 +8,10 @@ use crate::ui;
|
||||
use anyhow::Result;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::event::{
|
||||
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
|
||||
KeyModifiers, MouseEventKind,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
@@ -67,7 +70,7 @@ pub enum Net {
|
||||
SbxInput { from: String, bytes: Vec<u8> },
|
||||
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
|
||||
Ft(ft::Ft),
|
||||
Sys(String),
|
||||
Err(String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
@@ -97,6 +100,12 @@ pub struct App {
|
||||
pub sbx_scroll: usize,
|
||||
/// Whether the help overlay is showing.
|
||||
pub show_help: bool,
|
||||
/// A reconnect handshake is in flight (Ctrl-R after a disconnect).
|
||||
pub reconnecting: bool,
|
||||
/// Transient error shown as a popup over the clergy (cleared on next keypress).
|
||||
pub error: Option<String>,
|
||||
/// The room password this client authenticated with (shown by `/pw`).
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -118,6 +127,9 @@ impl App {
|
||||
chat_scroll: 0,
|
||||
sbx_scroll: 0,
|
||||
show_help: false,
|
||||
reconnecting: false,
|
||||
error: None,
|
||||
password: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +164,15 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
/// Surface an error: kept in chat scrollback for history AND shown as a
|
||||
/// popup over the clergy so it can't bleed onto / be overwritten at the
|
||||
/// input box. Dismissed by the next keypress.
|
||||
fn err(&mut self, text: impl Into<String>) {
|
||||
let t = text.into();
|
||||
self.sys(format!("✖ {t}"));
|
||||
self.error = Some(t);
|
||||
}
|
||||
|
||||
fn apply(&mut self, n: Net) {
|
||||
match n {
|
||||
Net::Init { lines, users } => {
|
||||
@@ -160,7 +181,7 @@ impl App {
|
||||
self.connected = true;
|
||||
self.chat_scroll = 0;
|
||||
self.sys(format!("joined as {} ⛧", self.me));
|
||||
self.sys("/sbx launch · /drive (Esc releases) · /send <file> · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||
self.sys("/sbx launch · /drive (Esc releases) · /send <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||
}
|
||||
Net::Message(l) => self.push_line(l),
|
||||
Net::Roster { users, capacity } => {
|
||||
@@ -222,10 +243,10 @@ impl App {
|
||||
self.sudoers = sudo;
|
||||
}
|
||||
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
||||
Net::Sys(t) => self.sys(t),
|
||||
Net::Err(t) => self.err(t),
|
||||
Net::Closed => {
|
||||
self.connected = false;
|
||||
self.sys("connection closed");
|
||||
self.sys("connection closed — press Ctrl-R to reconnect");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +258,16 @@ fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
|
||||
(sbx_h.saturating_sub(2).max(1), term_w.saturating_sub(2).max(1))
|
||||
}
|
||||
|
||||
/// One page of sandbox scrollback = the visible grid height (defaults to 10 if
|
||||
/// no sandbox is up). The run loop clamps `sbx_scroll` to the grid height anyway.
|
||||
fn sbx_page(app: &App) -> usize {
|
||||
app.sandbox
|
||||
.as_ref()
|
||||
.map(|v| v.parser.screen().size().0 as usize)
|
||||
.unwrap_or(10)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option<Vec<u8>> {
|
||||
match code {
|
||||
KeyCode::Char(c) => {
|
||||
@@ -271,7 +302,7 @@ fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Stream a payload to the coven as `_ft` chunks (background, paced).
|
||||
/// Stream a payload to the clergy as `_ft` chunks (background, paced).
|
||||
fn spawn_send(id: String, payload: Arc<Vec<u8>>, out: UnboundedSender<WsMsg>, room: Arc<fernet::Fernet>) {
|
||||
tokio::spawn(async move {
|
||||
for (seq, chunk) in payload.chunks(ft::CHUNK).enumerate() {
|
||||
@@ -332,11 +363,11 @@ fn handle_ft(
|
||||
if let Some(t) = app.transfers.remove(&id) {
|
||||
if t.accepted {
|
||||
if ft::sha256_hex(&t.buf) != t.meta.sha256 {
|
||||
app.sys(format!("✖ {} — SHA-256 mismatch, discarded", t.meta.name));
|
||||
app.err(format!("{} — SHA-256 mismatch, discarded", t.meta.name));
|
||||
} else {
|
||||
match ft::save(downloads, &t.meta, &t.buf) {
|
||||
Ok(p) => app.sys(format!("⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.buf.len()))),
|
||||
Err(e) => app.sys(format!("save failed: {e}")),
|
||||
Err(e) => app.err(format!("save failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,12 +379,12 @@ fn handle_ft(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
let ws = net::connect(&session).await?;
|
||||
let (mut write, read) = ws.split();
|
||||
pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
|
||||
let (tx, mut rx) = unbounded_channel::<Net>();
|
||||
let app_tx = tx.clone();
|
||||
tokio::spawn(net::reader(read, session.room.clone(), tx));
|
||||
let mut write = net::open(&session, tx.clone()).await?;
|
||||
// Carries the result of a background reconnect handshake back to the loop.
|
||||
let (recon_tx, mut recon_rx) = unbounded_channel::<std::result::Result<Session, String>>();
|
||||
|
||||
// All outgoing frames funnel through here so background tasks (file chunks,
|
||||
// PTY relay) can transmit without owning the socket.
|
||||
@@ -370,18 +401,25 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
|
||||
|
||||
let mut app = App::new(session.username.clone());
|
||||
app.password = params.password.clone();
|
||||
let mut events = EventStream::new();
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(50));
|
||||
|
||||
let result = loop {
|
||||
// Apply the sandbox scrollback offset (0 = follow live).
|
||||
let sbs = app.sbx_scroll;
|
||||
//
|
||||
// vt100 0.15.2 panics ("subtract with overflow" in grid::visible_rows)
|
||||
// if the scrollback offset ever exceeds the visible grid height: it does
|
||||
// `rows_len - offset` on usize without clamping. Cap our offset to the
|
||||
// grid height so a fast scroll can never cross that line and crash us.
|
||||
if let Some(v) = &mut app.sandbox {
|
||||
v.parser.set_scrollback(sbs);
|
||||
let rows = v.parser.screen().size().0 as usize;
|
||||
app.sbx_scroll = app.sbx_scroll.min(rows);
|
||||
v.parser.set_scrollback(app.sbx_scroll);
|
||||
}
|
||||
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
|
||||
break Err(e.into());
|
||||
@@ -405,10 +443,30 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
maybe = events.next() => {
|
||||
match maybe {
|
||||
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
|
||||
app.error = None; // any keypress dismisses the error popup
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
|
||||
break Ok(());
|
||||
}
|
||||
if app.show_help {
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(k.code, KeyCode::Char('r'))
|
||||
&& !app.connected
|
||||
{
|
||||
// Reconnect: re-run the SRP handshake off-thread so the UI
|
||||
// stays responsive, then re-attach the websocket on success.
|
||||
if !app.reconnecting {
|
||||
app.reconnecting = true;
|
||||
app.sys("⛧ reconnecting…");
|
||||
let p = params.clone();
|
||||
let rtx = recon_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let r = net::authenticate(
|
||||
&p.ip, p.port, &p.user, &p.password, p.no_tls, p.insecure,
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
let _ = rtx.send(r);
|
||||
});
|
||||
}
|
||||
} else if app.show_help {
|
||||
app.show_help = false; // any key dismisses the overlay
|
||||
} else if k.code == KeyCode::F(1) {
|
||||
app.show_help = true; // F1 from any mode
|
||||
@@ -422,6 +480,12 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
} else if app.driving {
|
||||
if k.code == KeyCode::Esc {
|
||||
app.driving = false;
|
||||
} else if k.code == KeyCode::PageUp {
|
||||
// Scroll the shared shell's scrollback without releasing the
|
||||
// drive: PgUp/PgDn aren't forwarded to the PTY anyway.
|
||||
app.sbx_scroll = (app.sbx_scroll + sbx_page(&app)).min(2000);
|
||||
} else if k.code == KeyCode::PageDown {
|
||||
app.sbx_scroll = app.sbx_scroll.saturating_sub(sbx_page(&app));
|
||||
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
|
||||
if let Some(sb) = &mut broker {
|
||||
// I own the sandbox: write straight to the PTY — instant,
|
||||
@@ -437,7 +501,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
let line = app.input.trim().to_string();
|
||||
app.input.clear();
|
||||
app.chat_scroll = 0; // jump back to live on send
|
||||
handle_command(&line, &mut app, &mut active_send, &mut send_seq,
|
||||
handle_command(&line, &mut app, &mut theme, &mut active_send, &mut send_seq,
|
||||
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
|
||||
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term);
|
||||
}
|
||||
@@ -476,6 +540,27 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Event::Mouse(m))) => {
|
||||
// Mouse wheel scrolls the sandbox terminal if one is up
|
||||
// (incl. while driving), otherwise the chat — mirrors ↑/↓.
|
||||
match m.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
if app.sandbox.is_some() {
|
||||
app.sbx_scroll = (app.sbx_scroll + 3).min(2000);
|
||||
} else {
|
||||
app.chat_scroll = (app.chat_scroll + 3).min(app.lines.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
if app.sandbox.is_some() {
|
||||
app.sbx_scroll = app.sbx_scroll.saturating_sub(3);
|
||||
} else {
|
||||
app.chat_scroll = app.chat_scroll.saturating_sub(3);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => break Err(e.into()),
|
||||
_ => {}
|
||||
}
|
||||
@@ -529,6 +614,36 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
recon = recon_rx.recv() => {
|
||||
if let Some(result) = recon {
|
||||
app.reconnecting = false;
|
||||
match result {
|
||||
Ok(s) => {
|
||||
session = s;
|
||||
match net::open(&session, tx.clone()).await {
|
||||
Ok(w) => {
|
||||
write = w;
|
||||
app.sys("⛧ websocket re-attached — syncing…");
|
||||
// If we host the sandbox, re-announce it so the
|
||||
// rest of the house re-syncs the shared shell.
|
||||
if let Some((be, _)) = &broker_meta {
|
||||
if let Some(v) = &app.sandbox {
|
||||
let (rows, cols) = v.parser.screen().size();
|
||||
send_frame(&out_tx, &session.room, json!({
|
||||
"_sbx":"status","state":"ready",
|
||||
"backend": be.label(),"rows": rows,"cols": cols
|
||||
}));
|
||||
broadcast_acl(&out_tx, &session.room, &app);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => app.err(format!("reconnect failed: {e}")),
|
||||
}
|
||||
}
|
||||
Err(e) => app.sys(format!("reconnect failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
pty = pty_rx.recv() => {
|
||||
if let Some(mut bytes) = pty {
|
||||
// Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips,
|
||||
@@ -571,7 +686,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
|
||||
}
|
||||
}
|
||||
disable_raw_mode()?;
|
||||
execute!(term.backend_mut(), LeaveAlternateScreen)?;
|
||||
execute!(term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
term.show_cursor()?;
|
||||
result
|
||||
}
|
||||
@@ -585,6 +700,7 @@ enum BrokerMsg {
|
||||
fn handle_command(
|
||||
line: &str,
|
||||
app: &mut App,
|
||||
theme: &mut Theme,
|
||||
active_send: &mut Option<ActiveSend>,
|
||||
send_seq: &mut u64,
|
||||
broker: &mut Option<sbx::Sandbox>,
|
||||
@@ -601,6 +717,32 @@ fn handle_command(
|
||||
let room = &session.room;
|
||||
if line == "/help" || line == "/?" {
|
||||
app.show_help = true;
|
||||
} else if line == "/pw" || line == "/password" {
|
||||
// Show the room password locally (never broadcast). Handy when the
|
||||
// server's password was autogenerated and you need to read it off / share
|
||||
// it out-of-band to invite someone into the room.
|
||||
if app.password.is_empty() {
|
||||
app.sys("⛧ no room password (joined without one)");
|
||||
} else {
|
||||
app.sys(format!("⛧ room password: {}", app.password));
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/theme") {
|
||||
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
|
||||
let name = rest.trim();
|
||||
if name.is_empty() {
|
||||
app.sys(format!("vestments: {} — /theme <name>", Theme::available().join(" · ")));
|
||||
} else {
|
||||
match Theme::by_name(name) {
|
||||
Ok(t) => {
|
||||
*theme = t;
|
||||
app.sys(format!("donned the {name} vestments"));
|
||||
}
|
||||
Err(_) => app.err(format!(
|
||||
"no theme '{name}' — try: {}",
|
||||
Theme::available().join(" · ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
} else if line == "/drive" {
|
||||
// Mobile-friendly alternative to F2 (no function key needed).
|
||||
if app.sandbox.is_none() {
|
||||
@@ -624,7 +766,7 @@ fn handle_command(
|
||||
}));
|
||||
app.sys(format!("offered {} ({}) — waiting for an /accept", name, ft::human(size)));
|
||||
}
|
||||
Err(e) => app.sys(format!("send failed: {e}")),
|
||||
Err(e) => app.err(format!("send failed: {e}")),
|
||||
}
|
||||
} else if line == "/accept" {
|
||||
if let Some(o) = app.pending_offer.take() {
|
||||
@@ -651,15 +793,25 @@ fn handle_command(
|
||||
if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||
app.sys("a sandbox is already running");
|
||||
} else {
|
||||
let backend = p.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
|
||||
let image = p.next().map(str::to_string).unwrap_or_else(|| backend.default_image().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);
|
||||
*launching = true;
|
||||
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
|
||||
app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label()));
|
||||
spawn_launch(backend, image, app.me.clone(), members, rows, cols,
|
||||
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
||||
// `--start` (alias `--start-daemon` / `-y`) opts in to booting
|
||||
// a stopped Docker daemon; everything else is positional.
|
||||
let args: Vec<&str> = p.collect();
|
||||
let start_daemon = args.iter().any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
||||
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||
let backend = pos.next().and_then(sbx::Backend::parse).unwrap_or(sbx::Backend::Local);
|
||||
let image = pos.next().map(str::to_string).unwrap_or_else(|| backend.default_image().to_string());
|
||||
|
||||
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() {
|
||||
app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first");
|
||||
} else {
|
||||
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
||||
*launching = true;
|
||||
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
|
||||
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,
|
||||
pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("stop") => {
|
||||
@@ -743,6 +895,7 @@ fn spawn_launch(
|
||||
members: Vec<String>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
start_daemon: bool,
|
||||
pty_tx: UnboundedSender<Vec<u8>>,
|
||||
broker_tx: UnboundedSender<BrokerMsg>,
|
||||
app_tx: UnboundedSender<Net>,
|
||||
@@ -751,10 +904,10 @@ fn spawn_launch(
|
||||
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)).await
|
||||
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon)).await
|
||||
};
|
||||
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
|
||||
let _ = app_tx.send(Net::Sys(format!("sandbox prepare failed: {e}")));
|
||||
let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e}")));
|
||||
let _ = broker_tx.send(BrokerMsg::Failed);
|
||||
return;
|
||||
}
|
||||
@@ -778,7 +931,7 @@ fn spawn_launch(
|
||||
let _ = broker_tx.send(BrokerMsg::Ready { sb, backend, name, rows, cols });
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app_tx.send(Net::Sys(format!("sandbox launch failed: {e}")));
|
||||
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));
|
||||
let _ = broker_tx.send(BrokerMsg::Failed);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50E8083969EDB767B0CF60\
|
||||
60279004E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DB\
|
||||
FBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73";
|
||||
|
||||
/// The SRP identity used by every cmd-chat / coven room (server hardcodes this).
|
||||
/// The SRP identity used by every cmd-chat / clergy room (server hardcodes this).
|
||||
/// The user's chosen display name is independent of this value.
|
||||
pub const SRP_IDENTITY: &[u8] = b"chat";
|
||||
|
||||
|
||||
+2
-2
@@ -152,7 +152,7 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("hh-ft-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = dir.join("note.txt");
|
||||
std::fs::write(&src, b"offering to the coven").unwrap();
|
||||
std::fs::write(&src, b"offering to the clergy").unwrap();
|
||||
|
||||
let (name, bytes, is_dir) = read_payload(src.to_str().unwrap()).unwrap();
|
||||
assert_eq!(name, "note.txt");
|
||||
@@ -161,7 +161,7 @@ mod tests {
|
||||
sha256: sha256_hex(&bytes), dir: false, from: "x".into() };
|
||||
let dl = dir.join("dl");
|
||||
let out = save(&dl, &offer, &bytes).unwrap();
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the coven");
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"offering to the clergy");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -84,11 +84,19 @@ fn main() -> Result<()> {
|
||||
theme,
|
||||
} => {
|
||||
let session = net::authenticate(&ip, port, &user, &password, no_tls, insecure)?;
|
||||
let params = net::ConnParams {
|
||||
ip,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
no_tls,
|
||||
insecure,
|
||||
};
|
||||
let theme = match theme {
|
||||
Some(p) => theme::Theme::load(&p)?,
|
||||
None => theme::Theme::default(),
|
||||
};
|
||||
tokio::runtime::Runtime::new()?.block_on(app::run(session, theme))
|
||||
tokio::runtime::Runtime::new()?.block_on(app::run(params, session, theme))
|
||||
}
|
||||
Cmd::Roomkey { password, room_salt_hex } => {
|
||||
let salt = hex::decode(room_salt_hex)?;
|
||||
|
||||
@@ -24,6 +24,21 @@ pub struct Session {
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
/// The credentials needed to (re)authenticate a Session — kept so the UI can
|
||||
/// re-run the SRP handshake and rejoin after a disconnect (AFK / server blip).
|
||||
#[derive(Clone)]
|
||||
pub struct ConnParams {
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub no_tls: bool,
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
/// The write half of a split websocket; outgoing frames are sent here.
|
||||
pub type WsSink = futures_util::stream::SplitSink<Ws, WsMsg>;
|
||||
|
||||
/// Full SRP handshake against the Sanic server. Returns a ready Session
|
||||
/// (room key derived, ws url built) but does not open the websocket.
|
||||
pub fn authenticate(
|
||||
@@ -99,6 +114,15 @@ pub async fn connect(session: &Session) -> Result<Ws> {
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Open the websocket for a session, spawn the reader task feeding `tx`, and
|
||||
/// hand back the write half. Used for the initial connect and every reconnect.
|
||||
pub async fn open(session: &Session, tx: UnboundedSender<Net>) -> Result<WsSink> {
|
||||
let ws = connect(session).await?;
|
||||
let (write, read) = ws.split();
|
||||
tokio::spawn(reader(read, session.room.clone(), tx));
|
||||
Ok(write)
|
||||
}
|
||||
|
||||
fn parse_users(v: &Value) -> Vec<User> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
|
||||
+77
-18
@@ -2,16 +2,46 @@
|
||||
//!
|
||||
//! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes
|
||||
//! are pumped out of a reader thread onto an mpsc channel; the broker encrypts
|
||||
//! them with the room key and relays them to the coven as `sbx pty_data` frames.
|
||||
//! them with the room key and relays them to the clergy as `sbx pty_data` frames.
|
||||
//! Input frames (`sbx pty_input`) are written back into the PTY. The server only
|
||||
//! ever sees ciphertext — identical trust model to chat/file transfer.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||
use std::io::{Read, Write};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Helper that ensures the Docker daemon is running (ships beside this source).
|
||||
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ensure-docker.sh");
|
||||
|
||||
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
|
||||
pub fn docker_daemon_up() -> bool {
|
||||
Command::new("docker")
|
||||
.arg("info")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let last = err.lines().last().unwrap_or("could not start the docker daemon");
|
||||
anyhow::bail!("{last}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which sandbox to summon. Multipass = strong isolation (default for real use),
|
||||
/// Docker = fast, Local = no isolation (dev/testing only).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -50,7 +80,7 @@ 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) -> Result<()> {
|
||||
pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> Result<()> {
|
||||
match backend {
|
||||
Backend::Local => Ok(()),
|
||||
Backend::Multipass => {
|
||||
@@ -60,24 +90,48 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
let st = Command::new("multipass")
|
||||
// Capture output so it can't bleed onto the TUI surface; surface
|
||||
// the failure reason through the returned error instead.
|
||||
let out = Command::new("multipass")
|
||||
.args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image])
|
||||
.status()
|
||||
.output()
|
||||
.context("multipass launch (is multipass installed?)")?;
|
||||
anyhow::ensure!(st.success(), "multipass launch failed");
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
anyhow::bail!("multipass launch failed: {}", err.lines().last().unwrap_or("").trim());
|
||||
}
|
||||
} else {
|
||||
let _ = Command::new("multipass").args(["start", name]).status();
|
||||
let _ = Command::new("multipass").args(["start", name])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Backend::Docker => {
|
||||
// The daemon must be up before any `docker` call. Rather than fail
|
||||
// with a raw connection error, start it (the caller confirmed via
|
||||
// `/sbx launch docker --start`).
|
||||
if !docker_daemon_up() {
|
||||
if start_daemon {
|
||||
start_docker_daemon().context("starting docker daemon")?;
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"docker daemon is not running — retry with `/sbx launch docker --start`"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Persistent container so we can exec in to provision users + shells.
|
||||
let _ = Command::new("docker").args(["rm", "-f", name]).status();
|
||||
let st = Command::new("docker")
|
||||
let _ = Command::new("docker").args(["rm", "-f", name])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
// Capture output so a failure can't paint over the TUI; the reason is
|
||||
// surfaced through the returned error (shown in the error popup).
|
||||
let out = Command::new("docker")
|
||||
.args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"])
|
||||
.status()
|
||||
.output()
|
||||
.context("docker run (is docker installed?)")?;
|
||||
anyhow::ensure!(st.success(), "docker run failed");
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
anyhow::bail!("docker run failed: {}", err.lines().last().unwrap_or("").trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -88,10 +142,12 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||
pub fn teardown(backend: Backend, name: &str) {
|
||||
match backend {
|
||||
Backend::Multipass => {
|
||||
let _ = Command::new("multipass").args(["delete", name, "--purge"]).status();
|
||||
let _ = Command::new("multipass").args(["delete", name, "--purge"])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
}
|
||||
Backend::Docker => {
|
||||
let _ = Command::new("docker").args(["rm", "-f", name]).status();
|
||||
let _ = Command::new("docker").args(["rm", "-f", name])
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
}
|
||||
Backend::Local => {}
|
||||
}
|
||||
@@ -125,7 +181,7 @@ fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a coven display name into a safe unix username.
|
||||
/// Sanitize a clergy display name into a safe unix username.
|
||||
pub fn unix_name(name: &str) -> String {
|
||||
let s: String = name
|
||||
.to_lowercase()
|
||||
@@ -139,12 +195,15 @@ pub fn unix_name(name: &str) -> String {
|
||||
fn mp(name: &str, args: &[&str]) {
|
||||
let mut a = vec!["exec", name, "--"];
|
||||
a.extend_from_slice(args);
|
||||
let _ = Command::new("multipass").args(a).status();
|
||||
// Null stdio so provisioning chatter never bleeds onto the TUI surface.
|
||||
let _ = Command::new("multipass").args(a)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
}
|
||||
fn dk(name: &str, args: &[&str]) {
|
||||
let mut a = vec!["exec", name];
|
||||
a.extend_from_slice(args);
|
||||
let _ = Command::new("docker").args(a).status();
|
||||
let _ = Command::new("docker").args(a)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null()).status();
|
||||
}
|
||||
|
||||
/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in)
|
||||
@@ -160,7 +219,7 @@ fn mp_revoke_sudo(name: &str, u: &str) {
|
||||
mp(name, &["sudo", "bash", "-c", &script]);
|
||||
}
|
||||
|
||||
/// Provision a real unix account per coven member inside the VM/container and
|
||||
/// Provision a real unix account per clergy member inside the VM/container and
|
||||
/// make the owner a superuser (sudoer). Returns the unix user the shared shell
|
||||
/// should run as. Blocking — call off the UI thread.
|
||||
pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String {
|
||||
@@ -219,7 +278,7 @@ pub struct Sandbox {
|
||||
|
||||
impl Sandbox {
|
||||
/// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto
|
||||
/// `out`; the caller relays them (encrypted) to the coven.
|
||||
/// `out`; the caller relays them (encrypted) to the clergy.
|
||||
pub fn launch(
|
||||
backend: Backend,
|
||||
name: &str,
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
//! occult-monochrome: black ground, white/grey ink, ⛧ accents. Override with a
|
||||
//! TOML file via `--theme <path>`.
|
||||
|
||||
use anyhow::Context;
|
||||
use ratatui::style::Color;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Where the bundled `*.toml` vestments live, so `/theme <name>` can resolve a
|
||||
/// bare name to a file at runtime (mirrors sbx.rs's script path const).
|
||||
pub const THEMES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/themes");
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Theme {
|
||||
@@ -18,8 +23,14 @@ pub struct Theme {
|
||||
pub system: Color,
|
||||
pub input: Color,
|
||||
pub roster_me: Color,
|
||||
/// Panel/background fill. `Reset` = the terminal's own background (no fill);
|
||||
/// a solid colour lifts the text onto a contrasting surface for legibility.
|
||||
pub bg: Color,
|
||||
/// Width of the roster column.
|
||||
pub roster_width: u16,
|
||||
/// Glyph flanking the "hack-house" title (and used for occult accents).
|
||||
/// Defaults to ⛧ (inverted pentagram); themes can pick their own sigil.
|
||||
pub sigil: String,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
@@ -37,7 +48,9 @@ impl Default for Theme {
|
||||
system: Color::Rgb(0xb4, 0x6c, 0xff), // system / occult = purple
|
||||
input: Color::Rgb(0x39, 0xff, 0x14),
|
||||
roster_me: Color::Rgb(0xff, 0x39, 0xc0), // you / owner = hot magenta
|
||||
bg: Color::Reset, // ride the terminal's own black
|
||||
roster_width: 22,
|
||||
sigil: "⛧".into(), // inverted pentagram
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +60,30 @@ impl Theme {
|
||||
let s = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&s)?)
|
||||
}
|
||||
|
||||
/// Resolve a bare vestment name (e.g. "neon") to a bundled `themes/<name>.toml`.
|
||||
/// Used by the in-session `/theme` command for live switching.
|
||||
pub fn by_name(name: &str) -> anyhow::Result<Self> {
|
||||
let path = format!("{THEMES_DIR}/{name}.toml");
|
||||
Self::load(&path).with_context(|| format!("theme '{name}' ({path})"))
|
||||
}
|
||||
|
||||
/// Names of the bundled vestments, sorted, for `/theme` with no argument.
|
||||
pub fn available() -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(THEMES_DIR)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.and_then(|f| f.strip_suffix(".toml"))
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+76
-19
@@ -9,6 +9,11 @@ use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||
// Paint the whole frame in the theme background first so every panel (and the
|
||||
// gaps between them) sits on the same surface. With bg = Reset this is a no-op
|
||||
// and we ride the terminal's own colour.
|
||||
f.render_widget(Block::default().style(Style::default().bg(theme.bg)), f.area());
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
@@ -39,6 +44,35 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||
if app.show_help {
|
||||
draw_help(f, f.area(), theme);
|
||||
}
|
||||
if let Some(msg) = &app.error {
|
||||
draw_error(f, f.area(), theme, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient error popup, anchored top-right over the clergy so it never bleeds
|
||||
/// onto the input box. Cleared by the next keypress (see the run loop).
|
||||
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
|
||||
let text = format!("⚠ {msg}");
|
||||
let w = area.width.saturating_sub(2).min(48).max(16);
|
||||
let inner = w.saturating_sub(2).max(1);
|
||||
let rows = (text.chars().count() as u16).div_ceil(inner) + 2;
|
||||
let h = rows.min(area.height.saturating_sub(2)).max(3);
|
||||
let x = area.x + area.width.saturating_sub(w + 1); // hug the right edge
|
||||
let y = area.y + 1; // just under the top bar, over the clergy
|
||||
let rect = Rect { x, y, width: w, height: h };
|
||||
f.render_widget(Clear, rect);
|
||||
let popup = Paragraph::new(text)
|
||||
.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(
|
||||
format!(" {} error · any key ", theme.sigil),
|
||||
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
f.render_widget(popup, rect);
|
||||
}
|
||||
|
||||
fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
@@ -68,9 +102,10 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
Span::styled(v.to_string(), dim),
|
||||
])
|
||||
};
|
||||
let head = |s: &str| Line::from(Span::styled(s.to_string(), acc));
|
||||
let sig = &theme.sigil;
|
||||
let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
|
||||
let lines = vec![
|
||||
head("⛧ COMMANDS (type in the input bar)"),
|
||||
head("COMMANDS (type in the input bar)"),
|
||||
kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"),
|
||||
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
|
||||
kv("/drive", "type into the shared shell (Esc releases)"),
|
||||
@@ -81,18 +116,22 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
kv("/send <file>", "offer a file to the room"),
|
||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
||||
kv("/theme [name]", "change vestments live: church | neon | crypt"),
|
||||
kv("/pw", "show this room's password (local only)"),
|
||||
kv("/help", "show / hide this menu"),
|
||||
Line::from(""),
|
||||
head("⛧ KEYS"),
|
||||
head("KEYS"),
|
||||
kv("Enter", "send chat message"),
|
||||
kv("F1 · /help", "toggle this help (any key closes it)"),
|
||||
kv("F2 · /drive", "take the shell · Esc releases it"),
|
||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
||||
kv("Up / Down", "scroll the sandbox terminal (when not driving)"),
|
||||
kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
|
||||
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
|
||||
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
|
||||
kv("Ctrl-Q", "quit hack-house"),
|
||||
Line::from(""),
|
||||
head("⛧ ROSTER GLYPHS"),
|
||||
head("ROSTER GLYPHS"),
|
||||
kv("⛧ owner ⚡ sudoer", "◆ may drive • member"),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
@@ -103,11 +142,12 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let w = centered(78, 90, area);
|
||||
f.render_widget(Clear, w);
|
||||
let help = Paragraph::new(lines)
|
||||
.style(Style::default().bg(theme.bg)) // fill the popup with the theme surface
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.title(Span::styled(
|
||||
" ⛧ hack-house — help ⛧ ",
|
||||
format!(" {0} hack-house — help {0} ", theme.sigil),
|
||||
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
)
|
||||
@@ -123,12 +163,14 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
|
||||
.rows(0, cols)
|
||||
.map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title))))
|
||||
.collect();
|
||||
let drive = if app.driving {
|
||||
" · DRIVING — type here · Esc to release".to_string()
|
||||
let drive = if app.driving && app.sbx_scroll > 0 {
|
||||
format!(" · DRIVING · ↑{} scrollback (PgDn=live)", app.sbx_scroll)
|
||||
} else if app.driving {
|
||||
" · DRIVING — type here · Esc · PgUp/wheel scroll".to_string()
|
||||
} else if app.sbx_scroll > 0 {
|
||||
format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll)
|
||||
} else {
|
||||
" · /drive (or F2) · ↑/↓ scroll".to_string()
|
||||
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
|
||||
};
|
||||
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
||||
let border = if app.driving { theme.accent } else { theme.border };
|
||||
@@ -142,10 +184,16 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
|
||||
|
||||
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
|
||||
let status = if app.connected { "🔒 e2e" } else { "✖ closed" };
|
||||
let status = if app.connected {
|
||||
"🔒 e2e"
|
||||
} else if app.reconnecting {
|
||||
"… reconnecting"
|
||||
} else {
|
||||
"✖ closed · Ctrl-R to reconnect"
|
||||
};
|
||||
let bar = Line::from(vec![
|
||||
Span::styled(
|
||||
" ⛧ hack-house ⛧ ",
|
||||
format!(" {0} hack-house {0} ", theme.sigil),
|
||||
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("· {status} "), Style::default().fg(theme.dim)),
|
||||
@@ -177,12 +225,20 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
|
||||
}
|
||||
|
||||
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let visible = area.height.saturating_sub(2) as usize;
|
||||
let len = app.lines.len();
|
||||
// Window ends `chat_scroll` lines above the live bottom.
|
||||
let end = len.saturating_sub(app.chat_scroll);
|
||||
let start = end.saturating_sub(visible);
|
||||
let lines: Vec<Line> = app.lines[start..end].iter().map(|l| fmt_line(l, app, theme)).collect();
|
||||
let inner_h = area.height.saturating_sub(2) as usize; // rows inside the border
|
||||
let text_w = area.width.saturating_sub(2).max(1); // wrap width inside the border
|
||||
let lines: Vec<Line> = app.lines.iter().map(|l| fmt_line(l, app, theme)).collect();
|
||||
|
||||
// Measure the TRUE wrapped height and scroll to the bottom. Selecting N
|
||||
// logical lines and letting the Paragraph top-anchor them clips any wrapped
|
||||
// line off the bottom — so the newest messages stay hidden until later ones
|
||||
// push them up into view (worse when the sandbox shrinks the chat pane).
|
||||
let total_rows = Paragraph::new(lines.clone())
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(text_w);
|
||||
let max_scroll = total_rows.saturating_sub(inner_h);
|
||||
let scroll = max_scroll.saturating_sub(app.chat_scroll) as u16;
|
||||
|
||||
let title = if app.chat_scroll > 0 {
|
||||
format!(" chat ↑{} (End=live) ", app.chat_scroll)
|
||||
} else {
|
||||
@@ -194,7 +250,8 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(title, Style::default().fg(theme.title))),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((scroll, 0));
|
||||
f.render_widget(chat, area);
|
||||
}
|
||||
|
||||
@@ -225,7 +282,7 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
||||
let roster = List::new(items).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(" coven ", Style::default().fg(theme.title))),
|
||||
.title(Span::styled(" clergy ", Style::default().fg(theme.title))),
|
||||
);
|
||||
f.render_widget(roster, area);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user