//! TUI application state, network event model, and the async run loop. use crate::ft; use crate::layout::Layout; use crate::net::{self, Session}; use crate::sbx; use crate::theme::Theme; use crate::ui; use anyhow::Result; use base64::engine::general_purpose::STANDARD; use base64::Engine; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use futures_util::{SinkExt, StreamExt}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio_tungstenite::tungstenite::Message as WsMsg; const SBX_NAME: &str = "hack-house"; #[derive(Clone)] pub struct ChatLine { pub ts: String, pub username: String, pub text: String, pub system: bool, } /// A power a user currently holds in the room. Badges stack: a user can be the /// `Host` *and* a `Sudoer` *and* a `Driver` at once. `Member` is the floor — /// returned only when none of the others apply. The order of the variants is /// the order badges are painted (host first). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Role { Host, Sudoer, Driver, Member, } #[derive(Clone)] pub struct User { pub user_id: String, pub username: String, } /// An in-progress incoming transfer we accepted. Chunks stream straight to a /// disk-backed `Sink` (created lazily on the first chunk) so a multi-GB payload /// never sits in RAM. struct Transfer { meta: ft::Offer, sink: Option, accepted: bool, } /// An outgoing transfer awaiting / serving an accept. The payload is streamed /// from `src` on disk, not held in memory. struct ActiveSend { id: String, src: std::path::PathBuf, /// `src` is a temp tar we built for a directory — delete it after sending. temp: bool, sending: bool, } /// Decoded events arriving from the websocket reader task. pub enum Net { Init { lines: Vec, users: Vec, }, Message(ChatLine), Roster { users: Vec, capacity: usize, }, Joined(String), Left(String), SbxStatus { backend: String, ready: bool, rows: u16, cols: u16, }, SbxResize { rows: u16, cols: u16, }, SbxData(Vec), SbxInput { from: String, bytes: Vec, }, Perm { owner: String, drivers: Vec, sudoers: Vec, }, Ft(ft::Ft), /// An AI agent is generating a reply (`on`) or has finished (`!on`). AiTyping { name: String, on: bool, }, /// Incremental reply text from a streaming AI agent. `text` is the reply so /// far (cumulative); `done` clears the live preview (the final, persisted /// chat message arrives separately as a normal `Message`). AiStream { name: String, text: String, done: bool, }, /// A local system notice produced off-thread (e.g. async Ollama probe). Sys(String), Err(String), /// An outgoing `/send` finished hashing off-thread and is staged for /// streaming — the run loop records it in `active_send` so a peer's `/accept` /// can start the chunk stream. The offer frame is emitted by the same task. SendReady { id: String, src: std::path::PathBuf, temp: bool, name: String, size: u64, to: Option, }, /// Relayed to the room when a member opens a shared VirtualBox VM locally, so /// the *other* party knows the appliance is live and can open their own copy. /// `by` is the server-authenticated launcher; the receiver skips its own echo. VmOpened { by: String, vm: String, }, Closed, } pub struct SbxView { pub parser: vt100::Parser, pub backend: String, } /// The arrow-navigable VirtualBox VM picker, opened by a bare `/sbx launch vbox` /// (or `/sbx gui`). Holds the locally-registered VM names and the highlighted /// row; Enter/Tab fills `/sbx launch vbox gui ` into the input, Esc dismisses. #[derive(Clone)] pub struct VboxPicker { pub vms: Vec, pub selected: usize, } /// 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)] pub enum Pane { Chat, Roster, Terminal, } pub struct App { pub me: String, pub lines: Vec, pub users: Vec, pub capacity: usize, pub input: String, pub connected: bool, pub sandbox: Option, pub driving: bool, pub owner: Option, pub drivers: std::collections::HashSet, /// Members whose VM unix account has sudo (superuser). Always includes owner. pub sudoers: std::collections::HashSet, pub pending_offer: Option, /// Open VM picker (arrow-navigable list of local VirtualBox VMs), or None. pub vbox_picker: Option, transfers: HashMap, /// Chat scrollback: lines scrolled up from the live bottom (0 = following). pub chat_scroll: usize, /// Sandbox terminal scrollback: rows scrolled up from the bottom. pub sbx_scroll: usize, /// Whether the help overlay is showing. pub show_help: bool, /// Vertical scroll offset (rows) into the help overlay when it doesn't fit. pub help_scroll: u16, /// Index of the currently highlighted help cluster (up/down moves it). pub help_selected: usize, /// Per-cluster expand/collapse state (left/right/Enter toggles); sized to the /// cluster count when the overlay opens. pub help_expanded: Vec, /// 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, /// The room password this client authenticated with (shown by `/pw`). pub password: String, /// AI agents currently generating a reply — drives the "thinking" spinner. pub ai_typing: std::collections::HashSet, /// Live, in-progress reply text per streaming agent, shown as a transient /// preview bubble until the final message lands. Keyed by agent name. pub ai_stream: std::collections::HashMap, /// Monotonic tick counter used to animate the AI spinner. pub spin: usize, /// When set, agents we summon are auto-granted sandbox drive on each launch /// (`/ai start allow`). Re-applied in the broker Ready handler, since /// launching a sandbox resets the ACL back to just the owner. pub agent_sbx_allow: bool, /// Display name the summoned agent joined under — derived from its model or /// profile at `/ai start` (e.g. "qwen2.5:3b", model name + parameter size). /// Tracked so the broker Ready handler can re-grant its drive and `/ai stop` /// can revoke the right ACL entry. pub agent_name: Option, /// Window layout: chat/terminal split, roster width, fullscreen zoom. Read /// by `ui::draw` and `sbx_dims`; mutated by F4/F5 + interactive editing. pub layout: Layout, /// Interactive layout editing: the pane currently selected for resizing /// (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, } impl App { fn new(me: String) -> Self { Self { me, lines: Vec::new(), users: Vec::new(), capacity: 0, input: String::new(), connected: false, sandbox: None, driving: false, owner: None, drivers: std::collections::HashSet::new(), sudoers: std::collections::HashSet::new(), pending_offer: None, vbox_picker: None, transfers: HashMap::new(), chat_scroll: 0, sbx_scroll: 0, show_help: false, help_scroll: 0, help_selected: 0, help_expanded: Vec::new(), reconnecting: false, error: None, password: String::new(), ai_typing: std::collections::HashSet::new(), ai_stream: std::collections::HashMap::new(), spin: 0, agent_sbx_allow: false, agent_name: None, layout: Layout::default(), focused_pane: 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). fn cycle_focus(&mut self) { let has_term = 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, }; } /// Append a chat line. Holds the viewport steady if scrolled up, and caps /// the in-memory backlog. fn push_line(&mut self, l: ChatLine) { self.lines.push(l); if self.chat_scroll > 0 { self.chat_scroll += 1; } const CAP: usize = 4000; if self.lines.len() > CAP { let drop = self.lines.len() - CAP; self.lines.drain(0..drop); self.chat_scroll = self.chat_scroll.min(self.lines.len().saturating_sub(1)); } } pub fn is_owner(&self) -> bool { self.owner.as_deref() == Some(self.me.as_str()) } pub fn can_drive(&self) -> bool { self.drivers.contains(&self.me) } /// The room host — whoever opened the house. The relay returns the roster in /// join order (its session store is an insertion-ordered map), and every /// client is served the *same* ordered roster, so "first occupant" is a /// stable, consistent host across all peers without any extra protocol. pub fn host(&self) -> Option<&str> { self.users.first().map(|u| u.username.as_str()) } /// Every role `name` currently holds, additive and in paint order. Reads the /// exact same `sudoers`/`drivers` sets the broker uses to gate shell input, /// so a rendered badge can never claim a power the room won't actually /// enforce. Always returns at least `Member`. pub fn roles_of(&self, name: &str) -> Vec { let mut roles = Vec::new(); if self.host() == Some(name) { roles.push(Role::Host); } if self.sudoers.contains(name) { roles.push(Role::Sudoer); } if self.drivers.contains(name) { roles.push(Role::Driver); } if roles.is_empty() { roles.push(Role::Member); } roles } fn sys(&mut self, text: impl Into) { self.push_line(ChatLine { ts: String::new(), username: String::new(), text: text.into(), system: true, }); } /// Open the help overlay fresh: first cluster highlighted, scrolled to top, /// all clusters collapsed (the empty expand vec renders as all-collapsed and /// is resized to the cluster count on the first navigation key). fn open_help(&mut self) { self.show_help = true; self.help_scroll = 0; self.help_selected = 0; self.help_expanded.clear(); } /// 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) { let t = text.into(); self.sys(format!("✖ {t}")); self.error = Some(t); } fn apply(&mut self, n: Net) { match n { Net::Init { lines, users } => { self.lines = lines; self.users = users; self.connected = true; self.chat_scroll = 0; self.sys(format!("joined as {} ⛧", self.me)); self.sys("/sbx launch · /drive (F2 releases) · /ai start · /ai · /send · /sendroom · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit"); } Net::Message(l) => self.push_line(l), Net::Roster { users, capacity } => { self.users = users; self.capacity = capacity; } Net::Joined(name) => self.sys(format!("{name} entered the house")), Net::Left(uid) => { if let Some(p) = self.users.iter().position(|u| u.user_id == uid) { let name = self.users.remove(p).username; self.ai_typing.remove(&name); // a departed agent isn't thinking self.ai_stream.remove(&name); // …nor streaming a reply self.sys(format!("{name} left")); } } Net::AiTyping { name, on } => { if on { self.ai_typing.insert(name); } else { self.ai_typing.remove(&name); } } Net::AiStream { name, text, done } => { if done { self.ai_stream.remove(&name); } else { // Streaming has started → drop the bare "thinking" spinner; // the live preview now signals the agent is working. self.ai_typing.remove(&name); self.ai_stream.insert(name, text); } } Net::SbxStatus { backend, ready, rows, cols, } => { if ready { // Converge on the shared shell. If we already track this // sandbox, just match its size — recreating the parser would // wipe scrollback and re-announce on every re-broadcast. The // owner re-sends `status:ready` whenever a member (re)joins, so // this MUST be idempotent for everyone already in the room; only // a genuinely new sandbox (none yet) is created and announced. match &mut self.sandbox { Some(v) => { v.parser.set_size(rows.max(1), cols.max(1)); v.backend = backend.clone(); } None => { self.sandbox = Some(SbxView { parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000), backend: backend.clone(), }); self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive")); } } } else { self.sandbox = None; self.driving = false; self.sbx_scroll = 0; self.owner = None; self.drivers.clear(); self.sudoers.clear(); self.sys("⛧ sandbox dismissed"); } } Net::SbxResize { rows, cols } => { if let Some(v) = &mut self.sandbox { v.parser.set_size(rows.max(1), cols.max(1)); } } Net::SbxData(bytes) => { if let Some(v) = &mut self.sandbox { v.parser.process(&bytes); } } Net::SbxInput { .. } => {} // broker enforces + writes in the run loop Net::Perm { owner, drivers, sudoers, } => { let new: std::collections::HashSet = drivers.into_iter().collect(); let sudo: std::collections::HashSet = sudoers.into_iter().collect(); if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) { self.sys(format!("⛧ {owner} is the superuser (sandbox owner)")); } if new.contains(&self.me) && !self.drivers.contains(&self.me) && self.owner.is_some() { self.sys("⛧ you were granted drive (F2 to take the shell)"); } else if !new.contains(&self.me) && self.drivers.contains(&self.me) { self.driving = false; self.sys("⛧ your drive permission was revoked"); } if sudo.contains(&self.me) && !self.sudoers.contains(&self.me) && self.owner.is_some() { self.sys("⛧ you were granted sudo (superuser) in the VM"); } self.owner = Some(owner).filter(|o| !o.is_empty()); self.drivers = new; self.sudoers = sudo; } Net::Ft(_) => {} // handled in the run loop (needs out channel + disk) Net::SendReady { .. } => {} // handled in the run loop (stages active_send) Net::Sys(t) => self.sys(t), Net::Err(t) => self.err(t), Net::VmOpened { by, vm } => { // Skip our own echo — we already saw the local "launched" line. if by != self.me { self.sys(format!( "⛧ {by} opened ‘{vm}’ locally — `/sbx gui {vm}` to open your own copy" )); } } Net::Closed => { self.connected = false; self.sys("connection closed — press Ctrl-R to reconnect"); } } } } /// Human label for a resizable pane (used in layout-editing hints). fn pane_label(p: Pane) -> &'static str { match p { Pane::Chat => "chat", Pane::Roster => "roster", Pane::Terminal => "terminal", } } /// Join saved-preset names for display, or "none" when there are none. fn once_or_none(names: Vec) -> String { if names.is_empty() { "none".to_string() } else { names.join(" · ") } } /// 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; ( 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> { match code { KeyCode::Char(c) => { if mods.contains(KeyModifiers::CONTROL) { let u = (c.to_ascii_uppercase() as u8).wrapping_sub(64); Some(vec![u & 0x1f]) } else { Some(c.to_string().into_bytes()) } } KeyCode::Enter => Some(vec![b'\r']), KeyCode::Backspace => Some(vec![0x7f]), KeyCode::Tab => Some(vec![b'\t']), // Esc must reach the shell (vim/less/etc. depend on it). Drive is released // with F2, not Esc, so this never collides with leaving the shell. KeyCode::Esc => Some(vec![0x1b]), KeyCode::Up => Some(b"\x1b[A".to_vec()), KeyCode::Down => Some(b"\x1b[B".to_vec()), KeyCode::Right => Some(b"\x1b[C".to_vec()), KeyCode::Left => Some(b"\x1b[D".to_vec()), _ => None, } } /// Queue an encrypted JSON frame for transmission (drained by the run loop). fn send_frame(out: &UnboundedSender, room: &fernet::Fernet, value: serde_json::Value) { let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes()))); } /// Read `path` and broadcast a file/dir offer. `to = Some(user)` targets one /// member (only they're prompted); `to = None` offers to the whole room. The /// payload is staged in `active_send` and streamed once an /accept arrives. fn offer_payload( app: &mut App, send_seq: &mut u64, out_tx: &UnboundedSender, room: &Arc, app_tx: &UnboundedSender, path: &str, to: Option<&str>, ) { *send_seq += 1; let id = format!("{}-{}", app.me, send_seq); let path = path.to_string(); let to = to.map(str::to_string); let out = out_tx.clone(); let room = room.clone(); let atx = app_tx.clone(); // Hashing (and tarring a directory) walks every byte — do it off the UI // thread so a multi-GB payload doesn't freeze the TUI. The task stages the // send via `Net::SendReady` *then* emits the offer, so a peer's `/accept` // can never race ahead of `active_send`. app.sys(format!("hashing {path}…")); tokio::task::spawn_blocking(move || match ft::prepare_send(&path) { Ok(s) => { let _ = atx.send(Net::SendReady { id: id.clone(), src: s.src, temp: s.temp, name: s.name.clone(), size: s.size, to: to.clone(), }); let mut frame = json!({ "_ft":"offer","id": id,"name": s.name,"size": s.size,"sha256": s.sha256,"dir": s.dir }); if let Some(t) = &to { frame["to"] = json!(t); } let _ = out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes()))); } Err(e) => { let _ = atx.send(Net::Err(format!("send failed: {e}"))); } }); } fn broadcast_acl(out: &UnboundedSender, room: &fernet::Fernet, app: &App) { let drivers: Vec<&String> = app.drivers.iter().collect(); let sudoers: Vec<&String> = app.sudoers.iter().collect(); send_frame( out, room, json!({ "_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers }), ); } /// Stream a payload from disk to the clergy as `_ft` chunks (background, paced). /// Reads `src` in `CHUNK` blocks so only one chunk is ever resident — a multi-GB /// appliance streams with flat memory. Deletes `src` afterward if it's a temp tar. fn spawn_send( id: String, src: std::path::PathBuf, temp: bool, out: UnboundedSender, room: Arc, ) { tokio::task::spawn_blocking(move || { use std::io::Read; let mut f = match std::fs::File::open(&src) { Ok(f) => f, Err(_) => return, }; let mut buf = vec![0u8; ft::CHUNK]; let mut seq = 0usize; loop { let n = match f.read(&mut buf) { Ok(0) => break, Ok(n) => n, Err(_) => break, }; let frame = json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(&buf[..n])}); if out .send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes()))) .is_err() { break; } seq += 1; std::thread::sleep(Duration::from_millis(2)); } let _ = out.send(WsMsg::Text( room.encrypt(json!({"_ft":"done","id": id}).to_string().as_bytes()), )); if temp { let _ = std::fs::remove_file(&src); } }); } fn handle_ft( f: ft::Ft, app: &mut App, active: &mut Option, out: &UnboundedSender, room: &Arc, downloads: &std::path::Path, ) -> Option { // Set to the saved path when a transfer completes & verifies, so the caller // can auto-bridge it into a running sandbox. let mut saved = None; match f { ft::Ft::Offer(o) => { if o.from == app.me { return None; // our own offer echo } // Direct send addressed to a specific member: everyone receives the // broadcast, but only the named recipient is prompted. if let Some(to) = &o.to { if to != &app.me { return None; } } app.sys(format!( "⛧ {} offers {} ({}{}){} — /accept or /reject", o.from, o.name, ft::human(o.size as usize), if o.dir { ", directory" } else { "" }, if o.to.is_some() { " directly to you" } else { "" }, )); app.transfers.insert( o.id.clone(), Transfer { meta: o.clone(), sink: None, accepted: false, }, ); app.pending_offer = Some(o); } ft::Ft::Accept(id) => { if let Some(a) = active.as_mut() { if a.id == id && !a.sending { a.sending = true; spawn_send(id, a.src.clone(), a.temp, out.clone(), room.clone()); app.sys("transfer accepted — sending…"); } } } ft::Ft::Reject(id) => { if active.as_ref().map(|a| a.id == id).unwrap_or(false) { app.sys("transfer rejected"); if let Some(a) = active.take() { if a.temp { let _ = std::fs::remove_file(&a.src); // discard the temp tar } } } } ft::Ft::Chunk { id, data } => { // Stream straight to the disk-backed sink (created lazily on the // first chunk). Enforce the declared size (clamped to STREAM_MAX) on // receipt — a sender can lie about `size` or just keep streaming, so // never let an accepted transfer grow past the cap. let mut overflow = false; let mut io_err: Option = None; if let Some(t) = app.transfers.get_mut(&id) { if t.accepted { if t.sink.is_none() { match ft::Sink::create(downloads, &id) { Ok(s) => t.sink = Some(s), Err(e) => io_err = Some(e.to_string()), } } if let Some(s) = t.sink.as_mut() { let cap = (t.meta.size).min(ft::STREAM_MAX); if s.written() + data.len() as u64 > cap { overflow = true; } else if let Err(e) = s.write(&data) { io_err = Some(e.to_string()); } } } } if overflow { if let Some(t) = app.transfers.remove(&id) { if let Some(s) = t.sink { s.abort(); } app.err(format!( "{} — transfer exceeds declared size (max {}), aborted", t.meta.name, ft::human((t.meta.size).min(ft::STREAM_MAX) as usize) )); } } else if let Some(e) = io_err { if let Some(t) = app.transfers.remove(&id) { if let Some(s) = t.sink { s.abort(); } app.err(format!("{} — write failed: {e}", t.meta.name)); } } } ft::Ft::Done(id) => { if let Some(t) = app.transfers.remove(&id) { if t.accepted { match t.sink { Some(s) => match s.finish() { Ok((tmp, sha)) => { if sha != t.meta.sha256 { let _ = std::fs::remove_file(&tmp); app.err(format!( "{} — SHA-256 mismatch, discarded", t.meta.name )); } else { match ft::commit(downloads, &t.meta, &tmp) { Ok(p) => { app.sys(format!( "⛧ saved {} ({}) — verified ✓", p.display(), ft::human(t.meta.size as usize) )); saved = Some(p); } Err(e) => app.err(format!("save failed: {e}")), } } } Err(e) => { app.err(format!("{} — finalize failed: {e}", t.meta.name)) } }, None => app.err(format!("{} — no data received", t.meta.name)), } } } if app .pending_offer .as_ref() .map(|o| o.id == id) .unwrap_or(false) { app.pending_offer = None; } } } saved } /// Put the terminal back the way we found it: leave raw mode, leave the /// alternate screen, stop mouse capture, show the cursor. Best-effort — every /// step is independent so one failing (e.g. already-restored) can't strand the /// rest. Safe to call more than once. fn restore_terminal() { let _ = disable_raw_mode(); let _ = execute!(std::io::stdout(), LeaveAlternateScreen, DisableMouseCapture); let _ = execute!(std::io::stdout(), crossterm::cursor::Show); } /// RAII: restores the terminal on drop. As long as a guard is alive, *any* exit /// from `run` — normal return, `?` error, or a panic unwinding through the /// frame — leaves the user's terminal (or tmux pane) usable instead of stuck in /// raw/alt-screen mode. struct TermGuard; impl Drop for TermGuard { fn drop(&mut self) { restore_terminal(); } } pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> { let (tx, mut rx) = unbounded_channel::(); let app_tx = tx.clone(); let 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::>(); // All outgoing frames funnel through here so background tasks (file chunks, // PTY relay) can transmit without owning the socket. let (out_tx, out_rx) = unbounded_channel::(); // The websocket writer runs on its own task so a slow / backpressured socket // (notably while relaying a sandbox PTY stream to a remote peer) can never // stall the UI loop's keyboard + chat handling. On reconnect the loop hands // the writer a fresh sink through `sink_tx`. let (sink_tx, sink_rx) = unbounded_channel::(); tokio::spawn(writer_task(write, out_rx, sink_rx)); let (pty_tx, mut pty_rx): (UnboundedSender>, UnboundedReceiver>) = unbounded_channel(); let (broker_tx, mut broker_rx) = unbounded_channel::(); let mut broker: Option = None; let mut broker_meta: Option<(sbx::Backend, String)> = None; let mut launching = false; let mut announced_dims: Option<(u16, u16)> = None; let mut active_send: Option = None; let mut send_seq: u64 = 0; // The local AI agent subprocess this client spawned via `/ai start`, if any. let mut agent: Option = None; let downloads = PathBuf::from("./downloads"); enable_raw_mode()?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let mut term = Terminal::new(CrosstermBackend::new(stdout))?; // From here on the terminal is in raw/alt-screen mode. The guard restores it // on every exit path (return, `?`, panic); the panic hook additionally prints // the panic *after* restoring, so a crash leaves a readable message in the // pane instead of garbled raw-mode output. let _term_guard = TermGuard; let default_panic = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { restore_terminal(); default_panic(info); })); // Graceful shutdown on signals: a `kill`, a closed tmux pane (SIGHUP), or a // Ctrl-C delivered while NOT in raw mode all break the loop cleanly so the // guard above can restore the terminal — no more being "booted" with a // broken pane. (In raw mode Ctrl-C arrives as a key event, handled below.) let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?; let mut app = App::new(session.username.clone()); app.password = params.password.clone(); // 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; 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). // // 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 { 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()); } if broker.is_some() { if let Ok(sz) = term.size() { let dims = sbx_dims(sz.width, sz.height, app.layout.effective_pty_pct()); if announced_dims != Some(dims) { announced_dims = Some(dims); if let Some(sb) = &broker { let _ = sb.resize(dims.0, dims.1); } send_frame( &out_tx, &session.room, json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}), ); } } } tokio::select! { biased; // keyboard first, so Ctrl-C / Esc are never starved by output floods maybe = events.next() => { match maybe { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { app.error = None; // any keypress dismisses the error popup // Ctrl-Q always quits. Ctrl-C quits too — *unless* we're // driving the sandbox, where it must reach the PTY as an // interrupt (handled in the `app.driving` branch below). if k.modifiers.contains(KeyModifiers::CONTROL) && (matches!(k.code, KeyCode::Char('q')) || (matches!(k.code, KeyCode::Char('c')) && !app.driving)) { break Ok(()); } if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('x')) && !app.driving { // Panic kill switch (sandbox owner): revoke every // non-owner driver, interrupt whatever is running in // the PTY, and re-broadcast the locked-down ACL. Cuts a // runaway agent (or human) off mid-command. Gated on // `!app.driving` so that while YOU hold the shell, Ctrl-X // reaches the PTY instead (nano's quit; key_to_pty sends // 0x18) — release with F2 first to arm the kill switch. if let Some(sb) = &mut broker { let owner = app.me.clone(); app.drivers.retain(|u| *u == owner); app.sudoers.retain(|u| *u == owner); app.agent_sbx_allow = false; let _ = sb.write_input(&[0x03]); // Ctrl-C into the shell broadcast_acl(&out_tx, &session.room, &app); app.sys("⛧ kill switch — revoked all drive + interrupted the shell"); } else { app.sys("kill switch is for the sandbox owner (you don't hold the PTY)"); } } else if k.modifiers.contains(KeyModifiers::CONTROL) && k.modifiers.contains(KeyModifiers::ALT) && matches!(k.code, KeyCode::Char('p')) { // Conjure a brand-new procedural vestment (not a bundled // preset): fresh palette + sigil, rolled from the clock. theme = Theme::random(); app.sys(format!( "{} conjured vestment '{}'", theme.sigil, theme.name )); } else 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.vbox_picker.is_some() { // Modal VM picker (from a bare `/sbx launch vbox`): arrow // keys move the highlight, Enter/Tab boots the selected VM // on this machine (host-frictionless path; a non-host who // needs install/import is steered to re-issue with `yes`), // Esc dismisses. Intercepts keys so the list stays put. match k.code { KeyCode::Up => { if let Some(p) = &mut app.vbox_picker { p.selected = p.selected.saturating_sub(1); } } KeyCode::Down => { if let Some(p) = &mut app.vbox_picker { p.selected = (p.selected + 1).min(p.vms.len().saturating_sub(1)); } } KeyCode::Enter | KeyCode::Tab => { let vm = app .vbox_picker .take() .and_then(|p| p.vms.into_iter().nth(p.selected)); if let Some(vm) = vm { launch_vbox_gui( &mut app, vm, false, &app_tx, &out_tx, &session.room, ); } } KeyCode::Esc => { app.vbox_picker = None; app.sys("⛧ picker dismissed"); } _ => {} // ignore other keys so the picker stays put } } else if app.show_help { // tmux-style nav: up/down highlight a cluster, left/right // (or Enter) collapse/expand it, PgUp/PgDn scroll the // overflow, only Esc closes so stray keys can't dismiss a // menu you're still reading. let count = ui::help_cluster_count(&theme); let max = term .size() .map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme)) .unwrap_or(0); // keep the expand state sized to the clusters if app.help_expanded.len() != count { app.help_expanded.resize(count, false); } match k.code { KeyCode::Up => { app.help_selected = app.help_selected.saturating_sub(1); } KeyCode::Down => { app.help_selected = (app.help_selected + 1).min(count.saturating_sub(1)); } KeyCode::Left => { if let Some(e) = app.help_expanded.get_mut(app.help_selected) { *e = false; // collapse the highlighted cluster } } KeyCode::Right => { if let Some(e) = app.help_expanded.get_mut(app.help_selected) { *e = true; // reveal the highlighted cluster } } KeyCode::Enter | KeyCode::Char(' ') => { if let Some(e) = app.help_expanded.get_mut(app.help_selected) { *e = !*e; // toggle } } KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10), KeyCode::PageDown => app.help_scroll = (app.help_scroll + 10).min(max), KeyCode::Home => app.help_scroll = 0, KeyCode::End => app.help_scroll = max, KeyCode::Esc | KeyCode::F(1) => { // Esc dismisses; F1 toggles the overlay back shut app.show_help = false; app.help_scroll = 0; } _ => {} // ignore other keys so the menu stays put } // clamp scroll in case collapsing shrank the content let max = term .size() .map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme)) .unwrap_or(0); app.help_scroll = app.help_scroll.min(max); } else if k.code == KeyCode::F(1) { app.open_help(); // F1 from any mode } else if k.code == KeyCode::F(2) { if app.sandbox.is_none() { } else if app.can_drive() { app.driving = !app.driving; } else { app.sys("you don't have drive permission — the owner can /grant you"); } } else if k.code == KeyCode::F(4) { // Fullscreen the terminal (cycle terminal → chat → split). // Caught before the drive branch so it works mid-session; // F-keys aren't forwarded to the shell anyway (key_to_pty). if app.sandbox.is_some() { app.layout.cycle_zoom(); announced_dims = None; // re-sync PTY to the new height app.sys(format!("⛧ {}", app.layout.describe())); } else { app.sys("no sandbox to fullscreen — /sbx launch first"); } } else if k.code == KeyCode::F(5) { // Enter/cycle interactive layout editing: pick a pane to // resize (Terminal → Chat → Roster → off). Clicking a pane // selects it directly; arrows then resize it. app.cycle_focus(); match app.focused_pane { Some(p) => app.sys(format!( "⛧ editing {} — arrows resize · Esc done", pane_label(p) )), None => app.sys("⛧ layout editing off"), } } else if app.focused_pane.is_some() && !app.driving { // Interactive layout editing: a pane is selected (via click // or F5). Arrows resize it live; Esc/Enter finishes. Sits // 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) => { 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())); } (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())); } _ => {} // ignore other keys while editing } } else if app.driving { // Esc is NOT a release key here — vim & friends need it, // so it's forwarded to the PTY like any other key (see // key_to_pty). Press F2 (handled above) to release the // shell back to chat. 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, // and Ctrl-C can't be queued behind outgoing output. let _ = sb.write_input(&bytes); } else { send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})); } } } else { match k.code { KeyCode::Enter => { 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 theme, &mut send_seq, &mut broker, &mut broker_meta, &mut launching, &mut announced_dims, &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term, &mut agent, ¶ms); } KeyCode::Backspace => { app.input.pop(); } // Scroll: ↑/↓ scroll the sandbox terminal if one is up, // otherwise the chat. PgUp/PgDn always scroll chat. KeyCode::Up => { if app.sandbox.is_some() { app.sbx_scroll = (app.sbx_scroll + 1).min(2000); } else { app.chat_scroll = (app.chat_scroll + 1).min(app.lines.len().saturating_sub(1)); } } KeyCode::Down => { if app.sandbox.is_some() { app.sbx_scroll = app.sbx_scroll.saturating_sub(1); } else { app.chat_scroll = app.chat_scroll.saturating_sub(1); } } KeyCode::PageUp => { app.chat_scroll = (app.chat_scroll + 10).min(app.lines.len().saturating_sub(1)); } KeyCode::PageDown => { app.chat_scroll = app.chat_scroll.saturating_sub(10); } KeyCode::Home => { app.chat_scroll = app.lines.len().saturating_sub(1); } KeyCode::End => { app.chat_scroll = 0; app.sbx_scroll = 0; } KeyCode::Char(c) => app.input.push(c), _ => {} } } } 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); } } // Left-click a pane to select it for interactive resize // (then arrows adjust it; Esc finishes). Skipped while // driving the shell or with an overlay up, so clicks there // don't hijack the terminal/help/picker. MouseEventKind::Down(MouseButton::Left) 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; } } } _ => {} } } Some(Err(e)) => break Err(e.into()), _ => {} } } net = rx.recv() => { // Drain a burst of incoming frames per turn. The reader funnels both // chat and high-volume `_sbx:data` terminal output through this one // channel, and the loop redraws once per turn — so handling a single // frame per redraw lets a busy sandbox stream bury chat arbitrarily far // back in the queue. Pulling up to a cap of ready frames now keeps chat // latency bounded no matter how hard the shared shell is scrolling. let Some(first) = net else { break Ok(()) }; let mut burst = vec![first]; drain_ready(&mut rx, &mut burst, 256); for ev in burst { match ev { Net::SbxInput { from, bytes } => { if let Some(sb) = &mut broker { if app.drivers.contains(&from) { let _ = sb.write_input(&bytes); } } } Net::Ft(f) => { // On a received, SHA-verified file, auto-bridge it into // a sandbox we host so the whole clergy can use it from // the shared shell. Runs off the UI thread (docker/mp // exec + tar stream) and reports back via the app channel. // A local backend already shares the host fs — nothing to do. if let Some(path) = handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads) { // A received VirtualBox appliance auto-imports so the VM // registers locally and the recipient can immediately // `/sbx launch vbox gui `. Off-thread (VBoxManage import // is slow); reports back via the app channel. let ext = path .extension() .and_then(|s| s.to_str()) .map(|s| s.to_ascii_lowercase()); if matches!(ext.as_deref(), Some("ova") | Some("ovf")) { let tx = app_tx.clone(); let ova = path.clone(); tokio::spawn(async move { let res = tokio::task::spawn_blocking(move || { sbx::import_appliance(&ova) }) .await; let _ = match res { Ok(Ok(vm)) => tx.send(Net::Sys(format!( "⛧ imported VM ‘{vm}’ — `/sbx launch vbox gui {vm}` to boot it" ))), Ok(Err(e)) => tx.send(Net::Sys(format!( "(received .ova not auto-imported: {e})" ))), Err(e) => tx.send(Net::Err(format!("import task: {e}"))), }; }); } if let Some((be, name)) = &broker_meta { if !matches!(be, sbx::Backend::Local) { let (be, name) = (*be, name.clone()); let run_user = sbx::run_user_for( be, app.owner.as_deref().unwrap_or(app.me.as_str()), ); let tx = app_tx.clone(); tokio::spawn(async move { let res = tokio::task::spawn_blocking(move || { sbx::push(be, &name, &run_user, &path) }) .await; let _ = match res { Ok(Ok(dest)) => tx.send(Net::Sys(format!( "⛧ bridged into sandbox → {dest}" ))), Ok(Err(e)) => tx.send(Net::Sys(format!( "(received file not bridged into sandbox: {e})" ))), Err(e) => tx.send(Net::Err(format!("bridge task: {e}"))), }; }); } } } } // The broker renders its sandbox locally from the PTY, so it // ignores its own echoed status/data; everyone else uses them. Net::SbxData(b) => { if broker.is_none() { if let Some(v) = &mut app.sandbox { v.parser.process(&b); } } } Net::SbxStatus { .. } if broker.is_some() => {} ev @ Net::Joined(_) => { // Someone (re)entered and missed everything broadcast // before they connected. If we host the sandbox, replay // it so it reappears for them: `status:ready` makes the // pane show up, the screen snapshot (`_sbx:data`) paints // its current contents instead of a blank pane, and the // ACL re-broadcast re-syncs grant state (also what makes // `/ai start allow` reach a freshly-joined agent). // The status/data handlers are idempotent, so members // already in the room aren't disturbed by the replay. if broker.is_some() { if let (Some(v), Some((be, _))) = (&app.sandbox, &broker_meta) { 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 })); let snap = v.parser.screen().contents_formatted(); send_frame(&out_tx, &session.room, json!({ "_sbx":"data","b64": STANDARD.encode(&snap) })); } broadcast_acl(&out_tx, &session.room, &app); } app.apply(ev); } Net::SendReady { id, src, temp, name, size, to } => { // The off-thread hash finished and emitted the offer; record // the staged send so a peer's /accept can start streaming. active_send = Some(ActiveSend { id, src, temp, sending: false }); match to { Some(t) => app.sys(format!( "offered {} ({}) to {} — waiting for an /accept", name, ft::human(size as usize), t )), None => app.sys(format!( "offered {} ({}) to the room — waiting for an /accept", name, ft::human(size as usize) )), } } other => app.apply(other), } } } msg = broker_rx.recv() => { match msg { Some(BrokerMsg::Ready { sb, backend, name, rows, cols }) => { broker = Some(sb); broker_meta = Some((backend, name)); announced_dims = Some((rows, cols)); launching = false; // Local sandbox view — broker renders straight from the PTY. app.sandbox = Some(SbxView { parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000), backend: backend.label().to_string(), }); app.sys(format!("⛧ sandbox summoned ({}) — /drive to take the shell", backend.label())); app.owner = Some(app.me.clone()); app.drivers.clear(); app.drivers.insert(app.me.clone()); app.sudoers.clear(); app.sudoers.insert(app.me.clone()); // owner = superuser if app.agent_sbx_allow { // Re-apply a `/ai start … allow` grant the launch reset. if let Some(n) = &app.agent_name { app.drivers.insert(n.clone()); } } send_frame(&out_tx, &session.room, json!({ "_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols })); broadcast_acl(&out_tx, &session.room, &app); } Some(BrokerMsg::Failed) => { launching = false; } 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) => { let _ = sink_tx.send(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, // no flood. Render locally now so the owner sees output instantly. while let Ok(more) = pty_rx.try_recv() { bytes.extend_from_slice(&more); if bytes.len() > 256 * 1024 { break; } } if let Some(v) = &mut app.sandbox { v.parser.process(&bytes); } send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})); } } _ = sigterm.recv() => { break Ok(()); } _ = sighup.recv() => { break Ok(()); } _ = tick.tick() => { app.spin = app.spin.wrapping_add(1); } } }; if let Some(mut sb) = broker.take() { sb.stop(); if let Some((be, name)) = broker_meta.take() { sbx::teardown(be, &name); } } if let Some(mut child) = agent.take() { let _ = child.kill(); let _ = child.wait(); } disable_raw_mode()?; execute!( term.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; term.show_cursor()?; result } enum BrokerMsg { Ready { sb: sbx::Sandbox, backend: sbx::Backend, name: String, rows: u16, cols: u16, }, Failed, } /// Owns the websocket write half and drains all outgoing frames off the UI /// loop. Because `sink.send().await` can block on a backpressured socket (e.g. /// while the server relays a sandbox PTY stream to a slow remote peer), keeping /// it here means that stall never starves keyboard input or chat rendering. A /// reconnect delivers a fresh sink via `sink_rx`; the reader task independently /// surfaces `Net::Closed`, so a dead sink here just drops frames until then. async fn writer_task( mut sink: net::WsSink, mut out_rx: UnboundedReceiver, mut sink_rx: UnboundedReceiver, ) { loop { tokio::select! { biased; // Swap in a reconnect's fresh sink before draining more frames. new_sink = sink_rx.recv() => { if let Some(s) = new_sink { sink = s; } } msg = out_rx.recv() => { let Some(first) = msg else { return }; // app exiting // Coalesce a burst (file chunks / PTY relay) into one batch. let mut batch = vec![first]; while let Ok(m) = out_rx.try_recv() { batch.push(m); if batch.len() >= 64 { break; } } for m in batch { if sink.send(m).await.is_err() { break; // sink dead — wait for a reconnect to replace it } } } } } } /// Pull up to `cap` *already-ready* items out of `rx` (without awaiting) in FIFO /// order, appending to `buf`. The UI loop uses this to drain a burst of incoming /// frames per turn so a high-volume `_sbx:data` stream can't bury chat behind a /// one-frame-per-redraw cap. fn drain_ready(rx: &mut UnboundedReceiver, buf: &mut Vec, cap: usize) { while buf.len() < cap { match rx.try_recv() { Ok(m) => buf.push(m), Err(_) => break, // empty or disconnected — nothing more to take right now } } } #[allow(clippy::too_many_arguments)] fn handle_command( line: &str, app: &mut App, theme: &mut Theme, send_seq: &mut u64, broker: &mut Option, broker_meta: &mut Option<(sbx::Backend, String)>, launching: &mut bool, announced_dims: &mut Option<(u16, u16)>, out_tx: &UnboundedSender, pty_tx: &UnboundedSender>, broker_tx: &UnboundedSender, app_tx: &UnboundedSender, session: &Session, term: &Terminal>, agent: &mut Option, params: &net::ConnParams, ) { let room = &session.room; if line == "/help" || line == "/?" { app.open_help(); } else if line == "/clear" || line == "/cls" { // Local-only: wipe this client's chat scrollback. Doesn't touch the // room — other peers keep their own history. app.lines.clear(); app.chat_scroll = 0; app.sys("⛧ chat cleared"); } 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 `, or bare `/theme` to list options. let name = rest.trim(); if name.is_empty() { app.sys(format!( "vestments: {} · random · save [name] — /theme ", Theme::available().join(" · ") )); } else if name == "random" { // Same as Ctrl+Alt+P — roll a fresh procedural vestment. *theme = Theme::random(); app.sys(format!( "{} conjured vestment '{}'", theme.sigil, theme.name )); } else if name == "save" || name.starts_with("save ") { // Persist the vestment you're currently wearing (e.g. a `random` // roll you like) to themes/.toml so it sticks around. Bare // `/theme save` reuses the theme's own generated name. let want = name[4..].trim(); let want = if want.is_empty() { theme.name.clone() } else { want.to_string() }; match theme.save(&want) { Ok(slug) => app.sys(format!( "{} saved vestment '{slug}' — re-don it anytime with /theme {slug}", theme.sigil )), Err(e) => app.err(format!("couldn't save vestment: {e}")), } } 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 let Some(rest) = line.strip_prefix("/layout") { // Named layout presets only — live resizing is interactive now (F4 // fullscreen, F5 or click a pane then arrows). `/layout` keeps just the // memorable verbs for saving/recalling arrangements, plus `reset`. // Loading a preset clears announced_dims so the run loop re-syncs (and // broadcasts) the live PTY size on the next tick. let rest = rest.trim(); let (cmd, arg) = rest .split_once(char::is_whitespace) .map(|(c, a)| (c, a.trim())) .unwrap_or((rest, "")); let mut resized = false; match cmd { "" => { app.sys(format!("⛧ layout: {}", app.layout.describe())); app.sys(" resize live: F4 fullscreen · F5 or click a pane, then arrows · Esc done"); app.sys(format!( " presets: {} — /layout save · load · rm ", once_or_none(Layout::available()) )); } "reset" | "default" => { let roster = app.layout.roster_width; app.layout = Layout::default(); app.layout.roster_width = roster; // keep your roster choice on reset resized = true; app.sys(format!("⛧ layout reset — {}", app.layout.describe())); } "save" if !arg.is_empty() => match app.layout.save(arg) { Ok(slug) => app.sys(format!( "⛧ saved layout '{slug}' — re-apply anytime with /layout load {slug}" )), Err(e) => app.err(format!("couldn't save layout: {e}")), }, "load" | "apply" if !arg.is_empty() => match Layout::by_name(arg) { Ok(l) => { app.layout = l; resized = true; app.sys(format!("⛧ loaded layout '{arg}' — {}", app.layout.describe())); } Err(_) => app.err(format!( "no saved layout '{arg}' — saved: {}", once_or_none(Layout::available()) )), }, "list" | "presets" | "ls" => { app.sys(format!("⛧ saved layouts: {}", once_or_none(Layout::available()))); } "rm" | "delete" | "del" if !arg.is_empty() => match Layout::remove(arg) { Ok(()) => app.sys(format!("⛧ deleted layout '{arg}'")), Err(e) => app.err(format!("{e}")), }, // Bare `/layout ` → load a saved preset if it exists. name => match Layout::by_name(name) { Ok(l) => { app.layout = l; resized = true; app.sys(format!("⛧ loaded layout '{name}' — {}", app.layout.describe())); } Err(_) => app.sys( "usage: /layout [save |load |list|rm |reset] — resize live with F4/F5 or click", ), }, } if resized { *announced_dims = None; // force the run loop to re-sync the PTY size } } else if line == "/drive" { // Mobile-friendly alternative to F2 (no function key needed). if app.sandbox.is_none() { app.sys("no sandbox running — /sbx launch first"); } else if app.can_drive() { app.driving = true; app.sys("⛧ drive mode ON — type into the shell (Esc reaches vim etc.) · press F2 to release"); } else { app.sys("you don't have drive permission — the owner can /grant you"); } } else if let Some(rest) = line.strip_prefix("/sendroom ") { // Offer a file/dir to the whole room — anyone may /accept. offer_payload(app, send_seq, out_tx, room, app_tx, rest.trim(), None); } else if let Some(rest) = line.strip_prefix("/send ") { // Direct send to one member: `/send `. Everyone receives the // broadcast offer, but only is prompted to /accept. let rest = rest.trim(); match rest.split_once(char::is_whitespace) { Some((who, path)) => { let (who, path) = (who.trim(), path.trim()); if who == app.me { app.sys("can't /send to yourself — use /sendroom for everyone"); } else if path.is_empty() { app.sys("usage: /send · /sendroom for everyone"); } else if !app.users.iter().any(|u| u.username == who) { let roster = app .users .iter() .map(|u| u.username.as_str()) .filter(|u| *u != app.me) .collect::>() .join(" · "); app.err(format!("no member '{who}' in the room — try: {roster}")); } else { offer_payload(app, send_seq, out_tx, room, app_tx, path, Some(who)); } } None => app.sys("usage: /send · /sendroom for everyone"), } } else if line == "/accept" { if let Some(o) = app.pending_offer.take() { send_frame(out_tx, room, json!({"_ft":"accept","id": o.id})); if let Some(t) = app.transfers.get_mut(&o.id) { t.accepted = true; } app.sys(format!("accepting {}…", o.name)); } else { app.sys("no pending offer"); } } else if line == "/reject" { if let Some(o) = app.pending_offer.take() { send_frame(out_tx, room, json!({"_ft":"reject","id": o.id})); app.transfers.remove(&o.id); app.sys("rejected the offer"); } else { app.sys("no pending offer"); } } else if let Some(rest) = line.strip_prefix("/sbx") { let mut p = rest.split_whitespace(); match p.next() { Some("launch") => { // `--start` (alias `--start-daemon` / `-y`) opts in to booting a // stopped Docker daemon; everything else is positional. The first // positional selects the backend: docker | multipass | vbox | // local. Each runs on the *invoker's* own machine. let args: Vec<&str> = p.collect(); let start_daemon = args .iter() .any(|a| matches!(*a, "--start" | "--start-daemon" | "-y")); // Consent token: if the selected backend's binary is missing, // `install` opts in to installing it (detect-then-install). It's // a positional keyword (not the image), so filter it out below. let want_install = args.iter().any(|a| matches!(*a, "install" | "--install")); let mut pos = args .iter() .copied() .filter(|a| !a.starts_with('-') && !matches!(*a, "install")); let first = pos.next(); if matches!(first, Some("virtualbox") | Some("vbox")) { // VirtualBox runs locally in its own GUI — host & guest each // open their own copy, nothing is relayed. It's independent of // the shared-PTY sandbox, so it bypasses the "already running" // guard. Grammar: `/sbx launch vbox [gui] [yes]`; a bare // `/sbx launch vbox` opens the arrow-navigable VM picker. let mut rest = pos.peekable(); if rest.peek() == Some(&"new") { // `/sbx launch vbox new [name]` — build a brand-new VM from // a cloud image (cloud-init toolchain), not an existing one. rest.next(); let name = rest.next().unwrap_or("hh-vbox").to_string(); launch_vbox_new(app, name, app.me.clone(), app_tx); } else { if rest.peek() == Some(&"gui") { rest.next(); // optional `gui` keyword (sugar) } match rest.next() { Some(vm) => { let confirmed = rest .any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok")); launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room); } None => open_vbox_picker(app), } } } else if app.sandbox.is_some() || broker.is_some() || *launching { app.sys("a sandbox is already running"); } else { let backend = first .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()); // Is this backend's binary present? Docker/Multipass can be // installed on consent; Local needs nothing and vbox is // handled in its own branch above. let installed = match backend { sbx::Backend::Docker => sbx::docker_installed(), sbx::Backend::Multipass => sbx::multipass_installed(), _ => true, }; let token = first.unwrap_or("docker"); if !installed && !want_install { app.err(format!( "{} is not installed — retry with `/sbx launch {token} install` to install it (needs sudo)", backend.label() )); } else if installed && 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 ./scripts/ensure-docker.sh in a terminal first"); } else { // install_first ⇒ binary missing + consent given: install // it off-thread before provisioning (a fresh Docker // install also leaves its daemon up). let install_first = !installed; 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 members: Vec = app.users.iter().map(|u| u.username.clone()).collect(); if install_first { app.sys(format!( "installing {} (needs sudo)… then summoning the sandbox", backend.label() )); } 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, pty_tx.clone(), broker_tx.clone(), app_tx.clone(), ); } } } Some("stop") => { if let Some(mut sb) = broker.take() { sb.stop(); if let Some((be, name)) = broker_meta.take() { tokio::task::spawn_blocking(move || sbx::teardown(be, &name)); } *announced_dims = None; send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"})); } else { app.sys("you are not hosting a sandbox"); } } Some("save") => { // `--local` (alias `-l`) also writes a portable, standalone copy // under hh-snapshots/ (a docker `.tar`) the saver fully owns; the // default keeps just the in-backend snapshot. The label is the // first non-flag arg. let args: Vec<&str> = p.collect(); let local = args.iter().any(|a| matches!(*a, "--local" | "-l")); let label = args .iter() .copied() .find(|a| !a.starts_with('-')) .unwrap_or("snap") .to_string(); if !is_snap_label(&label) { app.sys("snapshot label must be alphanumerics, '.', '_' or '-'"); } else if let Some((be, name)) = broker_meta.clone() { app.sys(format!( "saving sandbox state as '{label}'{}…", if local { " (+ local copy)" } else { "" } )); // Docker commits a *live* container, so its save is non- // disruptive. Multipass can only snapshot a powered-off // instance, so saving it necessarily stops the shared shell — // tear the live session down (same as `/sbx stop`, but WITHOUT // purging: the instance must survive so the snapshot does too). if be == sbx::Backend::Multipass { if let Some(mut sb) = broker.take() { sb.stop(); } broker_meta.take(); *announced_dims = None; send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"})); app.sys("multipass must power off to snapshot — stopping the shared shell, then saving (reload it with `/sbx load`)"); } let (tx, lbl) = (app_tx.clone(), label.clone()); tokio::spawn(async move { let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await; let _ = match res { Ok(Ok(desc)) => tx.send(Net::Sys(format!( "⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))), Ok(Err(e)) => tx.send(Net::Err(format!("save failed: {e}"))), Err(e) => tx.send(Net::Err(format!("save task: {e}"))), }; }); } else { app.sys("only the sandbox host can /sbx save (launch one first)"); } } Some("load") => match p.next() { None => app.sys("usage: /sbx load