feat(ui): stacking role badges in roster + chat; drop default pentagram

- introduce a Role model + App::host()/roles_of(): the host is the first
  occupant of the roster (shown the moment they join, no sandbox required),
  and roles are additive — a host who summoned a sandbox and can drive reads
  ✝◆. Badges read the same ACL the broker enforces, so they can never
  advertise a power the room won't honour
- render the stacked badge in the clergy panel and inline next to the author
  on every chat message; split VirtualBox commands into their own help cluster
- default styling: the startup handle prompt now prints ✝ (crypt sigil)
  instead of the inverted pentagram
- README: document VirtualBox VMs, snapshot save/load, AI streaming + recall,
  the badge system, and the expanded theme set
- gitignore out-of-tree experiments, the heavy demo-build kit, and logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-04 22:56:10 -07:00
parent 01e607dced
commit 5676216a2f
7 changed files with 970 additions and 101 deletions
+458 -38
View File
@@ -37,6 +37,18 @@ pub struct ChatLine {
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,
@@ -107,6 +119,13 @@ pub enum Net {
/// A local system notice produced off-thread (e.g. async Ollama probe).
Sys(String),
Err(String),
/// 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,
}
@@ -115,6 +134,33 @@ pub struct SbxView {
pub backend: String,
}
/// Where a staged `/sbx gui` launch is in its confirmation gauntlet. A local VM
/// boots a real window on the user's own machine and can require stopping other
/// hypervisors / installing VirtualBox, so each consequence gets its own
/// explicit `/sbx gui yes`. Any `/sbx gui cancel` (or a fresh `/sbx gui <vm>`)
/// abandons the pending launch without side effects.
#[derive(Clone)]
pub enum VmStage {
/// Gate 1 — confirm opening the VM locally at all.
Open,
/// Gate 2 — confirm STOPPING the VT-x holders that block a hardware VM.
CloseConflicts,
/// Gate 3 — confirm INSTALLING VirtualBox (it isn't present yet).
Install,
}
/// A `/sbx gui <vm>` launch awaiting confirmation. Detection is captured once
/// (at stage Open) so the prompts stay consistent through the gauntlet.
#[derive(Clone)]
pub struct PendingVm {
pub vm: String,
pub stage: VmStage,
/// Hypervisors holding VT-x that must stop before a hardware VM can boot.
pub conflicts: Vec<sbx::VtxHolder>,
/// VirtualBox isn't installed; the launch must install it first.
pub needs_install: bool,
}
/// Display handle the summoned Python agent joins under (see `spawn_agent`).
const AGENT_NAME: &str = "oracle";
@@ -132,6 +178,9 @@ pub struct App {
/// Members whose VM unix account has sudo (superuser). Always includes owner.
pub sudoers: std::collections::HashSet<String>,
pub pending_offer: Option<ft::Offer>,
/// A `/sbx gui <vm>` launch waiting on its confirmation gauntlet (open →
/// stop-conflicts → install). None when nothing is pending.
pub pending_vm: Option<PendingVm>,
transfers: HashMap<String, Transfer>,
/// Chat scrollback: lines scrolled up from the live bottom (0 = following).
pub chat_scroll: usize,
@@ -180,6 +229,7 @@ impl App {
drivers: std::collections::HashSet::new(),
sudoers: std::collections::HashSet::new(),
pending_offer: None,
pending_vm: None,
transfers: HashMap::new(),
chat_scroll: 0,
sbx_scroll: 0,
@@ -219,6 +269,35 @@ impl App {
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<Role> {
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<String>) {
self.push_line(ChatLine {
ts: String::new(),
@@ -353,6 +432,14 @@ impl App {
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
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");
@@ -1015,9 +1102,8 @@ async fn writer_task(
biased;
// Swap in a reconnect's fresh sink before draining more frames.
new_sink = sink_rx.recv() => {
match new_sink {
Some(s) => sink = s,
None => {}
if let Some(s) = new_sink {
sink = s;
}
}
msg = out_rx.recv() => {
@@ -1094,13 +1180,20 @@ fn handle_command(
} 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));
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/<slug>.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() };
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}",
@@ -1338,36 +1431,64 @@ fn handle_command(
}
Some("gui") => {
let gargs: Vec<&str> = p.collect();
let install = gargs.iter().any(|a| *a == "--install");
let name = gargs
.iter()
.copied()
.find(|a| !a.starts_with('-'))
.map(str::to_string);
match name {
None => app.sys("usage: /sbx gui <vm> [--install] (list VMs with /sbx vms)"),
Some(vm) => {
app.sys(format!("launching {vm} in the VirtualBox GUI…"));
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
if !sbx::vbox_installed() {
if install {
sbx::ensure_vbox_install()
.map_err(|e| format!("install failed: {e}"))?;
} else {
return Err("VirtualBox isn't installed — retry with `/sbx gui <vm> --install` (needs sudo), or run ./ensure-vbox.sh first".to_string());
}
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
let is_yes = matches!(first, Some("yes" | "y" | "go" | "confirm" | "ok"));
let is_no = matches!(first, Some("no" | "n" | "cancel" | "abort" | "stop"));
if is_no {
// A `/sbx gui cancel` at any gate: drop the launch, no side
// effects (nothing was stopped or installed yet).
if app.pending_vm.take().is_some() {
app.sys("cancelled — no VM launched, nothing stopped or installed");
} else {
app.sys("nothing to cancel");
}
} else if is_yes {
// Advance the gauntlet one gate.
match app.pending_vm.take() {
None => app.sys("no VM launch is waiting — start one with `/sbx gui <vm>`"),
Some(pend) => advance_vm(pend, app, app_tx, out_tx, room),
}
} else {
// A fresh `/sbx gui <vm>`: detect locally and pose gate 1.
let install_flag = gargs.contains(&"--install");
match first.map(str::to_string) {
None => app.sys(
"usage: /sbx gui <vm> [--install] · confirm with `/sbx gui yes`, abort with `/sbx gui cancel` (list VMs: /sbx vms)",
),
Some(vm) => {
let installed = sbx::vbox_installed();
if installed && sbx::vm_running(&vm) {
app.sys(format!(
"{vm} is already running — look for its window on your desktop"
));
} else {
// Local boot affects the user's own machine, so
// stage it behind explicit confirmation. Anyone
// in the room can run this — each client opens
// its own copy locally (host and guest alike).
let _ = install_flag; // consent now flows through `/sbx gui yes`
let conflicts = sbx::vtx_holders();
let mut warn = String::new();
if !conflicts.is_empty() {
warn.push_str(&format!(
"{} other VM(s) hold VT-x and may need to stop.",
conflicts.len()
));
}
sbx::gui_launch(&vm).map_err(|e| e.to_string())
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("{desc}"))),
Ok(Err(e)) => tx.send(Net::Err(e)),
Err(e) => tx.send(Net::Err(format!("gui task: {e}"))),
};
});
if !installed {
warn.push_str(" (VirtualBox isn't installed yet.)");
}
app.sys(format!(
"open {vm} locally? a VirtualBox window will launch on YOUR machine.{warn} → `/sbx gui yes` to continue · `/sbx gui cancel` to abort"
));
app.pending_vm = Some(PendingVm {
vm,
stage: VmStage::Open,
conflicts,
needs_install: !installed,
});
}
}
}
}
}
@@ -1588,7 +1709,122 @@ fn local_ollama_models() -> Result<Vec<String>, String> {
fn is_snap_label(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
/// Advance a staged `/sbx gui` launch by one confirmation gate. Each `/sbx gui
/// yes` calls this. Stage transitions are pure (app-thread) state changes; only
/// the *final* gate spawns the off-thread executor that actually stops
/// conflicts, installs, and boots the VM. A holder we can't stop cleanly aborts
/// the whole launch here rather than killing anything.
fn advance_vm(
pend: PendingVm,
app: &mut App,
app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
) {
// Refuse outright if any VT-x holder is one we won't touch (e.g. a stray
// qemu we don't recognise). Better to make the user decide than to kill it.
if let Some(bad) = pend.conflicts.iter().find(|h| !h.stoppable) {
app.err(format!(
"can't free VT-x automatically — {} is running and I won't kill it. Stop it yourself, then retry `/sbx gui {}`.",
bad.label, pend.vm
));
return;
}
match pend.stage {
VmStage::Open => {
if !pend.conflicts.is_empty() {
let names: Vec<String> = pend.conflicts.iter().map(|h| h.label.clone()).collect();
app.sys(format!(
"⚠ second confirmation: these hold the CPU's VT-x and block a VirtualBox VM — {}. They must STOP first (reversible — restart them afterwards). → `/sbx gui yes` to stop them & continue · `/sbx gui cancel` to abort",
names.join(", ")
));
app.pending_vm = Some(PendingVm {
stage: VmStage::CloseConflicts,
..pend
});
} else if pend.needs_install {
app.sys("VirtualBox isn't installed. → `/sbx gui yes` to install it now (needs sudo) · `/sbx gui cancel` to abort");
app.pending_vm = Some(PendingVm {
stage: VmStage::Install,
..pend
});
} else {
spawn_vm_execute(pend, app, app_tx, out_tx, room);
}
}
VmStage::CloseConflicts => {
if pend.needs_install {
app.sys("VirtualBox isn't installed. → `/sbx gui yes` to install it now (needs sudo) · `/sbx gui cancel` to abort");
app.pending_vm = Some(PendingVm {
stage: VmStage::Install,
..pend
});
} else {
spawn_vm_execute(pend, app, app_tx, out_tx, room);
}
}
VmStage::Install => spawn_vm_execute(pend, app, app_tx, out_tx, room),
}
}
/// Final step of the gauntlet: off-thread, stop any VT-x holders (reversibly),
/// install VirtualBox if needed, then boot the VM's GUI. Reports through the
/// app channel so the blocking work never stalls the TUI. On success it also
/// broadcasts a `_sbx:vm` frame so the *other* party sees the shared appliance
/// go live and can `/sbx gui <vm>` their own local copy — anyone in the room may
/// do so (the per-client consent gauntlet *is* the client's permission; this is
/// deliberately not owner-gated, unlike `/sbx save`).
fn spawn_vm_execute(
pend: PendingVm,
app: &mut App,
app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
) {
let PendingVm {
vm,
conflicts,
needs_install,
..
} = pend;
if conflicts.is_empty() {
app.sys(format!("launching {vm}’…"));
} else {
app.sys(format!(
"freeing VT-x ({} VM(s)) then launching {vm}’…",
conflicts.len()
));
}
let tx = app_tx.clone();
let out = out_tx.clone();
let room = room.clone();
let announce_vm = vm.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
for h in &conflicts {
sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?;
}
if needs_install && !sbx::vbox_installed() {
sbx::ensure_vbox_install().map_err(|e| format!("install failed: {e}"))?;
}
sbx::gui_launch(&vm).map_err(|e| e.to_string())
})
.await;
let _ = match res {
Ok(Ok(desc)) => {
// Tell the room the shared VM is live (others can open their own).
let frame = json!({"_sbx": "vm", "vm": announce_vm});
let _ = out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())));
tx.send(Net::Sys(format!("{desc}")))
}
Ok(Err(e)) => tx.send(Net::Err(e)),
Err(e) => tx.send(Net::Err(format!("gui task: {e}"))),
};
});
}
#[allow(clippy::too_many_arguments)]
@@ -1712,7 +1948,10 @@ fn spawn_agent(
cmd.arg("--profile").arg(p);
}
None => {
cmd.arg("--provider").arg("ollama").arg("--model").arg(model);
cmd.arg("--provider")
.arg("ollama")
.arg("--model")
.arg(model);
}
}
cmd.stdin(Stdio::null())
@@ -1733,9 +1972,132 @@ fn spawn_agent(
#[cfg(test)]
mod tests {
use super::drain_ready;
use super::{advance_vm, drain_ready, App, Net, PendingVm, Role, User, VmStage};
use crate::sbx::VtxHolder;
use std::sync::Arc;
use tokio::sync::mpsc::unbounded_channel;
fn holder(kind: &str, stoppable: bool) -> VtxHolder {
VtxHolder {
label: format!("{kind} VM"),
kind: kind.into(),
instance: String::new(),
stoppable,
}
}
fn test_room() -> Arc<fernet::Fernet> {
Arc::new(fernet::Fernet::new(&fernet::Fernet::generate_key()).expect("key"))
}
/// The consent gauntlet must walk Open → CloseConflicts when a (stoppable)
/// hypervisor holds VT-x, posing a SECOND confirmation rather than acting.
#[test]
fn advance_vm_open_with_conflict_asks_to_stop_it() {
let (app_tx, _ar) = unbounded_channel::<Net>();
let (out_tx, _or) = unbounded_channel();
let room = test_room();
let mut app = App::new("alice".into());
let pend = PendingVm {
vm: "WinLab".into(),
stage: VmStage::Open,
conflicts: vec![holder("multipass", true)],
needs_install: false,
};
advance_vm(pend, &mut app, &app_tx, &out_tx, &room);
let p = app.pending_vm.as_ref().expect("still pending after gate 1");
assert!(matches!(p.stage, VmStage::CloseConflicts));
assert!(app
.lines
.last()
.unwrap()
.text
.contains("second confirmation"));
}
/// Safety invariant: if ANY holder is non-stoppable (something we won't kill),
/// the gauntlet refuses — it surfaces an error and drops the pending launch
/// rather than advancing or touching the unknown process.
#[test]
fn advance_vm_refuses_when_a_holder_is_unstoppable() {
let (app_tx, _ar) = unbounded_channel::<Net>();
let (out_tx, _or) = unbounded_channel();
let room = test_room();
let mut app = App::new("alice".into());
let pend = PendingVm {
vm: "WinLab".into(),
stage: VmStage::Open,
conflicts: vec![holder("unknown", false)],
needs_install: false,
};
advance_vm(pend, &mut app, &app_tx, &out_tx, &room);
assert!(app.pending_vm.is_none(), "must not advance past a refusal");
assert!(app.error.is_some(), "must surface an error");
}
/// With no VT-x conflict but VirtualBox absent, Open → Install (gate 3).
#[test]
fn advance_vm_open_needing_install_moves_to_install_gate() {
let (app_tx, _ar) = unbounded_channel::<Net>();
let (out_tx, _or) = unbounded_channel();
let room = test_room();
let mut app = App::new("alice".into());
let pend = PendingVm {
vm: "WinLab".into(),
stage: VmStage::Open,
conflicts: vec![],
needs_install: true,
};
advance_vm(pend, &mut app, &app_tx, &out_tx, &room);
let p = app.pending_vm.as_ref().expect("pending at install gate");
assert!(matches!(p.stage, VmStage::Install));
}
/// After confirming the stop, if install is still needed the gauntlet steps
/// CloseConflicts → Install (it does not jump straight to launching).
#[test]
fn advance_vm_closeconflicts_needing_install_steps_to_install() {
let (app_tx, _ar) = unbounded_channel::<Net>();
let (out_tx, _or) = unbounded_channel();
let room = test_room();
let mut app = App::new("alice".into());
let pend = PendingVm {
vm: "WinLab".into(),
stage: VmStage::CloseConflicts,
conflicts: vec![holder("docker", true)],
needs_install: true,
};
advance_vm(pend, &mut app, &app_tx, &out_tx, &room);
let p = app.pending_vm.as_ref().expect("pending at install gate");
assert!(matches!(p.stage, VmStage::Install));
}
/// A `_sbx:vm` broadcast from ANOTHER member surfaces a "open your own copy"
/// notice to the room.
#[test]
fn vm_opened_from_peer_notifies_the_room() {
let mut app = App::new("alice".into());
app.apply(Net::VmOpened {
by: "bob".into(),
vm: "WinLab".into(),
});
let last = &app.lines.last().unwrap().text;
assert!(last.contains("bob") && last.contains("WinLab"));
}
/// ...but our OWN echo is skipped — we already printed the local "launched"
/// line, so we must not double-report it.
#[test]
fn vm_opened_self_echo_is_skipped() {
let mut app = App::new("alice".into());
let before = app.lines.len();
app.apply(Net::VmOpened {
by: "alice".into(),
vm: "WinLab".into(),
});
assert_eq!(app.lines.len(), before, "self-echo must not add a line");
}
/// `drain_ready` pulls a bounded burst in FIFO order and stops at the cap.
#[tokio::test]
async fn drain_ready_is_fifo_and_capped() {
@@ -1789,6 +2151,64 @@ mod tests {
}
}
assert!(saw_chat, "chat frame must be observed");
assert!(turns <= 4, "chat took {turns} turns to surface (expected <= 4)");
assert!(
turns <= 4,
"chat took {turns} turns to surface (expected <= 4)"
);
}
fn user(name: &str) -> User {
User {
user_id: format!("id-{name}"),
username: name.into(),
}
}
/// The host is the first occupant of the roster, and an empty roster has no
/// host — so a freshly-built app (no users yet) confers the title on nobody.
#[test]
fn host_is_first_roster_member() {
let mut app = App::new("alice".into());
assert_eq!(app.host(), None, "empty roster has no host");
app.users = vec![user("alice"), user("bob")];
assert_eq!(app.host(), Some("alice"));
// Host follows roster order, not who `me` is.
app.users = vec![user("bob"), user("alice")];
assert_eq!(app.host(), Some("bob"));
}
/// The host wears the Host badge with zero sandbox activity — Option A: the
/// title shows the moment they're in the room, not only after a launch.
#[test]
fn host_badge_shows_without_a_sandbox() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
assert_eq!(app.roles_of("alice"), vec![Role::Host]);
assert_eq!(app.roles_of("bob"), vec![Role::Member]);
}
/// Badges stack: a host who summoned a sandbox (sudoer) and can drive holds
/// all three at once, in paint order Host → Sudoer → Driver.
#[test]
fn roles_stack_additively() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
app.sudoers.insert("alice".into());
app.drivers.insert("alice".into());
assert_eq!(
app.roles_of("alice"),
vec![Role::Host, Role::Sudoer, Role::Driver]
);
}
/// A non-host can hold powers independently of the host title: bob granted
/// drive shows Driver only — never Host, and not a bare Member.
#[test]
fn non_host_powers_are_independent_of_the_title() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
app.drivers.insert("bob".into());
assert_eq!(app.roles_of("bob"), vec![Role::Driver]);
assert_eq!(app.roles_of("alice"), vec![Role::Host]);
}
}
+4 -2
View File
@@ -99,7 +99,9 @@ fn main() -> Result<()> {
match net::authenticate(&ip, port, &name, &password, no_tls, insecure) {
Ok(s) => break s,
Err(e) if interactive => {
eprintln!("{e:#}\n that handle didn't work (taken or full?) — pick another.");
eprintln!(
"{e:#}\n that handle didn't work (taken or full?) — pick another."
);
name = prompt_handle()?;
}
Err(e) => return Err(e),
@@ -168,7 +170,7 @@ fn main() -> Result<()> {
fn prompt_handle() -> Result<String> {
use std::io::Write;
loop {
print!(" choose your handle: ");
print!(" choose your handle: ");
std::io::stdout().flush()?;
let mut s = String::new();
if std::io::stdin().read_line(&mut s)? == 0 {
+31 -12
View File
@@ -77,7 +77,7 @@ impl Theme {
pub fn random() -> Theme {
let mut r = Rng::seeded();
let base = r.range(0.0, 360.0); // base hue for the surface/ink family
// Accent sits at an analogous or complementary offset for contrast.
// Accent sits at an analogous or complementary offset for contrast.
let accent_hue = (base + *r.pick(&[30.0, 150.0, 180.0, 210.0, 330.0_f32])) % 360.0;
let bg = hsv(base, r.range(0.22, 0.5), r.range(0.06, 0.12)); // deep tinted slate
@@ -134,12 +134,15 @@ impl Theme {
/// the file, the `name` field, and the `/theme` argument all agree.
pub fn save(&self, name: &str) -> anyhow::Result<String> {
let slug = slugify(name);
anyhow::ensure!(!slug.is_empty(), "give the vestment a name (letters/digits)");
anyhow::ensure!(
!slug.is_empty(),
"give the vestment a name (letters/digits)"
);
let mut t = self.clone();
t.name = slug.clone();
let path = format!("{THEMES_DIR}/{slug}.toml");
let body = toml::to_string_pretty(&t)
.with_context(|| format!("serialize vestment '{slug}'"))?;
let body =
toml::to_string_pretty(&t).with_context(|| format!("serialize vestment '{slug}'"))?;
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
Ok(slug)
}
@@ -165,18 +168,30 @@ fn slugify(name: &str) -> String {
}
/// Occult glyphs the randomizer can stamp as the title sigil.
const SIGILS: [&str; 12] = [
"", "", "", "", "", "", "", "", "", "", "", "",
];
const SIGILS: [&str; 12] = ["", "", "", "", "", "", "", "", "", "", "", ""];
/// Arcane name fragments — `<adj>-<noun>` makes a memorable vestment name.
const NAME_ADJ: [&str; 16] = [
"ashen", "umbral", "votive", "hollow", "gilded", "wraith", "septic", "occult",
"molten", "veiled", "sallow", "rotting", "sacred", "cinder", "obsidian", "vesper",
"ashen", "umbral", "votive", "hollow", "gilded", "wraith", "septic", "occult", "molten",
"veiled", "sallow", "rotting", "sacred", "cinder", "obsidian", "vesper",
];
const NAME_NOUN: [&str; 16] = [
"reliquary", "ossuary", "vestment", "censer", "shroud", "chancel", "crypt", "sepulcher",
"litany", "chalice", "rood", "narthex", "thurible", "psalter", "ossein", "vigil",
"reliquary",
"ossuary",
"vestment",
"censer",
"shroud",
"chancel",
"crypt",
"sepulcher",
"litany",
"chalice",
"rood",
"narthex",
"thurible",
"psalter",
"ossein",
"vigil",
];
/// Convert HSV (h in degrees 0360, s/v in 01) to an 8-bit-per-channel RGB
@@ -272,7 +287,11 @@ roster_width = 24
let t = Theme::random();
// Name is `<adj>-<noun>` and the sigil is one of the occult glyphs.
assert!(t.name.contains('-'), "name should be adj-noun: {}", t.name);
assert!(SIGILS.contains(&t.sigil.as_str()), "sigil from set: {}", t.sigil);
assert!(
SIGILS.contains(&t.sigil.as_str()),
"sigil from set: {}",
t.sigil
);
assert_eq!(t.roster_width, 22);
// Surface must stay dark enough to read light ink against it.
if let Color::Rgb(r, g, b) = t.bg {
+134 -40
View File
@@ -1,6 +1,6 @@
//! ratatui rendering — top bar, chat, roster, input.
use crate::app::{App, ChatLine};
use crate::app::{App, ChatLine, Role};
use crate::theme::Theme;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
@@ -149,36 +149,85 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
HelpCluster {
title: "SANDBOX",
items: vec![
kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"),
kv(
"/sbx launch [backend]",
"summon a sandbox: local | docker | multipass",
),
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv("/sbx save [label]", "snapshot state (docker image; survives stop)"),
kv("/sbx load <label>", "launch a fresh sandbox from a saved snapshot"),
kv(
"/sbx save [label]",
"snapshot state (docker image; survives stop)",
),
kv(
"/sbx load <label>",
"launch a fresh sandbox from a saved snapshot",
),
kv("/sbx snaps", "list saved snapshots"),
kv("/sbx vms", "list local VirtualBox VMs"),
kv("/sbx gui <vm>", "boot a VirtualBox VM locally in its GUI"),
kv("/drive · F2", "type into the shared shell (Esc releases)"),
kv(
"/drive · F2",
"type into the shared shell (Esc releases)",
),
],
},
HelpCluster {
title: "VIRTUALBOX (local GUI VM)",
items: vec![
kv("/sbx vms", "detect VirtualBox + list local VMs"),
kv(
"/sbx gui <vm> [--install]",
"open a shared VM locally — host & guest each get their own copy",
),
kv(
"/sbx gui yes",
"confirm a pending launch (advance the consent gate)",
),
kv(
"/sbx gui cancel",
"abort a pending launch (nothing stopped or installed)",
),
],
},
HelpCluster {
title: "AI AGENTS",
items: vec![
kv("/ai start [model|profile]", "spawn an agent (ollama tag or models.toml profile)"),
kv("/ai start <model> allow", "spawn + auto-grant the agent sandbox drive"),
kv(
"/ai start [model|profile]",
"spawn an agent (ollama tag or models.toml profile)",
),
kv(
"/ai start <model> allow",
"spawn + auto-grant the agent sandbox drive",
),
kv("/ai stop", "dismiss the agent you started"),
kv("/ai <question>", "ask an agent in the room (/ai <name> <q> if many)"),
kv("/ai <name> !<task>", "have a granted agent run a task in the sandbox"),
kv(
"/ai <question>",
"ask an agent in the room (/ai <name> <q> if many)",
),
kv(
"/ai <name> !<task>",
"have a granted agent run a task in the sandbox",
),
kv("/ai list", "list AI agents present + their provider/model"),
kv("/ai models", "show models the active agent's backend can serve"),
kv(
"/ai models",
"show models the active agent's backend can serve",
),
],
},
HelpCluster {
title: "PERMISSIONS (owner)",
items: vec![
kv("/grant <user|agent>", "let a member OR an AI agent drive the shell"),
kv(
"/grant <user|agent>",
"let a member OR an AI agent drive the shell",
),
kv("/revoke <user|agent>", "take back sandbox drive permission"),
kv("/sudo <user>", "delegate VM superuser (real sudo)"),
kv("/unsudo <user>", "revoke VM superuser"),
kv("/ai start <name> allow", "shortcut: grant the agent drive at spawn"),
kv(
"/ai start <name> allow",
"shortcut: grant the agent drive at spawn",
),
],
},
HelpCluster {
@@ -193,8 +242,14 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
title: "APPEARANCE",
items: vec![
kv("/theme [name]", &theme_help),
kv("/theme save [name]", "keep the vestment you're wearing for reuse"),
kv("Ctrl+Alt+P · /theme random", "conjure a random vestment (palette + sigil)"),
kv(
"/theme save [name]",
"keep the vestment you're wearing for reuse",
),
kv(
"Ctrl+Alt+P · /theme random",
"conjure a random vestment (palette + sigil)",
),
],
},
HelpCluster {
@@ -204,19 +259,33 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
kv("F1 · /help", "toggle this help"),
kv("Ctrl-C (while driving)", "interrupt the running command"),
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
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(
"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("/pw", "show this room's password (local only)"),
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
],
},
HelpCluster {
title: "ROSTER GLYPHS",
items: vec![kv(
&format!("{} owner ⚡ sudoer", theme.sigil),
"◆ may drive • member",
)],
title: "ROSTER GLYPHS (badges stack)",
items: vec![
kv(
&format!("{} host", theme.sigil),
"opened the house (first in the room)",
),
kv("⚡ sudoer", "VM superuser in the sandbox"),
kv("◆ driver", "may drive the shell"),
kv("• member", "present — no extra powers"),
],
},
]
}
@@ -231,7 +300,9 @@ pub fn help_cluster_count(theme: &Theme) -> usize {
/// scroll math always matches what's painted.
fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
let clusters = help_clusters(theme);
let acc = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
let acc = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD);
let sel = Style::default()
.fg(theme.bg)
.bg(theme.accent)
@@ -260,7 +331,9 @@ fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
}
lines.push(Line::from(Span::styled(
" ↑/↓ select · ←/→ or Enter expand · PgUp/PgDn scroll · Esc closes",
Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
)));
lines
}
@@ -375,8 +448,12 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
} else {
theme.other
};
// Author's current badge inline, so a message's authority is legible right
// in the transcript — not only in the clergy panel.
let badges = role_badges(app, &l.username, theme);
Line::from(vec![
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
Span::styled(format!("{badges} "), Style::default().fg(theme.dim)),
Span::styled(
l.username.clone(),
Style::default().fg(name_color).add_modifier(Modifier::BOLD),
@@ -400,12 +477,16 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
lines.push(Line::from(vec![
Span::styled(
format!("{name} "),
Style::default().fg(theme.other).add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.other)
.add_modifier(Modifier::BOLD),
),
Span::styled("", Style::default().fg(theme.dim)),
Span::styled(
text.as_str(),
Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
),
]));
}
@@ -436,26 +517,39 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
f.render_widget(chat, area);
}
/// Glyph for a single role. The host sigil is theme-dependent (✝ for crypt, ⛧
/// for the default), the rest are fixed.
fn role_glyph(role: Role, theme: &Theme) -> &str {
match role {
Role::Host => theme.sigil.as_str(),
Role::Sudoer => "",
Role::Driver => "",
Role::Member => "",
}
}
/// The stacked badge string for `name` — e.g. `⛧⚡◆` for a host who summoned a
/// sandbox and can drive, or a lone `•` for a plain member. Single source of
/// truth shared by the roster and the chat author prefix so both always agree
/// with each other and with what the broker enforces.
fn role_badges(app: &App, name: &str, theme: &Theme) -> String {
app.roles_of(name)
.into_iter()
.map(|r| role_glyph(r, theme))
.collect()
}
fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let items: Vec<ListItem> = app
.users
.iter()
.map(|u| {
let me = u.username == app.me;
// <sigil> owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member
let owner = app.owner.as_deref() == Some(u.username.as_str());
let mark = if owner {
theme.sigil.as_str()
} else if app.sudoers.contains(&u.username) {
""
} else if app.drivers.contains(&u.username) {
""
} else {
""
};
// Stacked badges: <sigil> host · ⚡ sudoer · ◆ driver · • member.
let badges = role_badges(app, &u.username, theme);
let color = if me { theme.roster_me } else { theme.other };
ListItem::new(Line::from(Span::styled(
format!(" {mark} {}", u.username),
format!(" {badges} {}", u.username),
Style::default().fg(color),
)))
})