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

11
.gitignore vendored
View File

@ -17,6 +17,17 @@ secured_console_chat.egg-info/
# Editor / OS
.idea/
# Logs / runtime scratch
*.log
err.log
# Out-of-tree experiments (not part of hack-house)
/experiments/
/headroom/
# Heavy / superseded demo-build kit (real demos live in video-toolkit)
/docs/demo/
# Project scratch
/i-try/
test_rsa.py

View File

@ -33,11 +33,13 @@ Encrypted chat that runs in your terminal. You host the server, you control the
- **Zero-knowledge server** — relays only ciphertext; cannot read messages, files, or terminal output
- **RAM only** — nothing persisted on the server; close it and history is gone
- **Shared sandbox** — summon a disposable `local` / `docker` / `multipass` box the whole room can watch and drive
- **Real permissions** — the sandbox owner grants/revokes *drive* (keyboard) and *sudo* (VM superuser) per user
- **Local-first AI agent** — `/ai start` summons an in-room AI that runs against *your own* [Ollama](https://ollama.com) (no API key, nothing leaves your machine); model-agnostic, addressed-only, end-to-end encrypted like every other client
- **Snapshot save/load** — freeze a sandbox to a named snapshot and restore it later (`/sbx save` · `/sbx load` · `/sbx snaps`)
- **Local VirtualBox VMs** — `/sbx vms` detects VirtualBox and lists your VMs; `/sbx gui <vm>` opens a desktop VM locally for the room to gather around — per-user consent gate, with automatic resolution of VT-x conflicts (Docker Desktop / multipass)
- **Real permissions** — the host grants/revokes *drive* (keyboard) and *sudo* (VM superuser) per user; **stacking roster badges** show exactly who holds what, both in the clergy panel and inline on every chat message
- **Local-first AI agent** — `/ai start` summons an in-room AI that runs against *your own* [Ollama](https://ollama.com) (no API key, nothing leaves your machine); replies **stream token-by-token** with **in-RAM semantic recall** of the conversation for context; model-agnostic, addressed-only, end-to-end encrypted like every other client
- **Encrypted file transfer** — `/send` → `/accept` with SHA-256 verification
- **TLS** — self-signed by default, or bring your own cert; `--no-tls` for local/Tailscale use
- **Themes** — switchable "vestments" (`church` · `neon` · `crypt`)
- **Themes** — seven switchable "vestments" (`crypt` default · `church` · `neon` · `blush` · `matrix` · `wraith` · `goldcrypt`), plus a live randomizer
## Layout
@ -157,6 +159,9 @@ Type to chat. Slash commands and keys:
| `/ai models` | Models the active agent can serve — or, with no agent, your local Ollama tags |
| `/sbx launch [local\|docker\|multipass] [image]` | Summon the shared sandbox |
| `/sbx stop` | Tear down the sandbox you host |
| `/sbx save [label]` · `/sbx load <label>` · `/sbx snaps` | Snapshot the sandbox, restore one, or list snapshots |
| `/sbx vms` | Detect VirtualBox and list local VMs |
| `/sbx gui <vm> [--install]` | Open a local VirtualBox desktop VM for the room (consent-gated) |
| `/drive` · `F2` | Take the shared shell (`Esc` releases) |
| `/grant <user>` · `/revoke <user>` | Owner: delegate/withdraw drive |
| `/sudo <user>` · `/unsudo <user>` | Owner: delegate/withdraw VM superuser |
@ -180,6 +185,19 @@ server only ever sees ciphertext (same trust model as chat).
Tear it down with `/sbx stop` (purges the VM/container).
**Snapshots.** Freeze the current sandbox to a named checkpoint with `/sbx save
[label]`, list what you've stored with `/sbx snaps`, and restore one later with
`/sbx load <label>` — handy for resuming a half-built environment or replaying a
demo from a known-good state.
**Local VirtualBox VMs.** Separate from the relayed sandbox, `/sbx vms` detects a
VirtualBox install and lists your VMs, and `/sbx gui <vm>` boots one as a full
desktop VM **on your own machine** (`--install` offers to install VirtualBox
first if it's missing). It's not owner-gated — the per-user confirmation gate
*is* the permission, so everyone opens their own copy. If a hardware hypervisor
(Docker Desktop, multipass) is holding VT-x, hack-house detects the conflict and
offers to stop it so the VM can boot.
### Driving the shell
The shared terminal is **watch-by-default**: everyone sees the live output, but
@ -204,8 +222,14 @@ Permissions are enforced at **two layers**:
"may type" and "may run privileged commands" are independent and enforced by
the OS itself.
The roster shows each member's status: owner, sudoer (⚡), driver (◆), or
member (•).
The roster shows each member's status with **stacking badges**: host (the
theme's sigil, e.g. `✝`), sudoer (⚡), driver (◆), and member (•). They're
additive — a host who summoned a sandbox and can drive reads `✝⚡◆` — and the
host badge appears the moment someone is first in the room, before any sandbox
exists. The same badge is rendered inline next to the author on every chat
message, so a message's authority is legible right in the transcript, not only
in the side panel. Because the badges read the exact ACL the sandbox enforces,
they can never advertise a power the room won't honour.
### Sharing files & directories
@ -257,10 +281,12 @@ directly:
### Themes (vestments)
Three bundled themes — `crypt` (default, neutral monochrome), `church` (neon),
and `neon`. Switch live with `/theme <name>`, list them with bare `/theme`, or
load your own TOML at launch with `--theme <path>` (see `hh/themes/`). Each
theme defines its own sigil, colours, and roster width.
Seven bundled themes — `crypt` (default, neutral monochrome, `✝` sigil),
`church`, `neon`, `blush`, `matrix`, `wraith`, and `goldcrypt`. Switch live with
`/theme <name>`, list them with bare `/theme`, roll a fresh randomized vestment
with `Ctrl+Alt+P` (and save it to disk), or load your own TOML at launch with
`--theme <path>` (see `hh/themes/`). Each theme defines its own sigil, colours,
and roster width.
### Staying connected

297
hh/film-virtualbox.sh Executable file
View File

@ -0,0 +1,297 @@
#!/usr/bin/env bash
# film-virtualbox.sh — RECORD the "VirtualBox, local GUI VM" beat and compose a
# narrated, subtitled demo. Sibling of film-save-load.sh.
#
# connect alice → /sbx vms (detect VirtualBox + list VMs)
# → /sbx gui NE-Lab-Win11 (boots the real Windows VM locally, in its GUI)
# → capture the VM window coming up → title/result cards, TTS, subtitles
#
# Two captures: a clean terminal asciinema cast (the commands) and an x11grab of
# *only* the VirtualBox window region (not the whole desktop). Then video-forge
# stitches title→terminal→gui→result; edge-tts narration + an SRT are muxed/burned
# on top.
#
# Usage: hh/film-virtualbox.sh [--keep] [--no-render] [--no-vm]
# --keep leave server/sessions up; don't power off the VM
# --no-render stop after the captures (skip compose)
# --no-vm skip booting the VM (reuse an already-running one for GUI grab)
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VTK="$HOME/coding/video-toolkit"
FORGE_PY="$HOME/anaconda3/bin/python3"
pick_port() { local p; for p in $(seq 4240 4290); do ss -ltn 2>/dev/null | grep -q ":$p " || { echo "$p"; return; }; done; echo 4173; }
PORT="${PORT:-$(pick_port)}"
PW="${PW:-malware-bless}"
VM="${VM:-NE-Lab-Win11}"
PY="$REPO/.venv/bin/python"
BIN="$REPO/hh/target/debug/hack-house"
# Two side-by-side client panes (alice | bob) in one recorded window, so the
# cast shows BOTH parties of the collaboration at once.
WIN_W=190; WIN_H=42
SRV_SESS="vbxfilm-srv"
RUN_SESS="vbxfilm"
REC_SESS="vbxfilm-rec"
OUTDIR="$REPO/docs/demo"
CAST="$OUTDIR/virtualbox.cast"
TERM_MP4="$OUTDIR/vbx-term.mp4"
GUI_MP4="$OUTDIR/vbx-gui.mp4"
CUT_MP4="$OUTDIR/vbx-cut.mp4"
NARR_MP3="$OUTDIR/vbx-narration.mp3"
SRT="$OUTDIR/vbx.srt"
FINAL_MP4="$OUTDIR/virtualbox.mp4"
MANIFEST="$OUTDIR/vbx-forge.json"
ACCENT="#39ff14"
GUI_SECS="${GUI_SECS:-18}"
KEEP=0; RENDER=1; BOOT_VM=1
for a in "$@"; do case "$a" in
--keep) KEEP=1 ;; --no-render) RENDER=0 ;; --no-vm) BOOT_VM=0 ;;
esac; done
GREEN=$'\e[32m'; RED=$'\e[31m'; YEL=$'\e[33m'; DIM=$'\e[2m'; RST=$'\e[0m'
step() { printf '\n%s== %s ==%s\n' "$YEL" "$*" "$RST"; }
ok() { printf '%s ok %s%s\n' "$GREEN" "$*" "$RST"; }
bad() { printf '%s XX %s%s\n' "$RED" "$*" "$RST"; }
note() { printf '%s %s%s\n' "$DIM" "$*" "$RST"; }
FAIL=0; fail() { bad "$*"; FAIL=1; }
cleanup() {
if [[ $KEEP -eq 1 ]]; then note "--keep: leaving sessions + VM up."; return; fi
step "cleanup"
tmux kill-session -t "$REC_SESS" 2>/dev/null
tmux kill-session -t "$RUN_SESS" 2>/dev/null
tmux kill-session -t "$SRV_SESS" 2>/dev/null
if [[ $BOOT_VM -eq 1 ]]; then
VBoxManage controlvm "$VM" poweroff >/dev/null 2>&1 && note "powered off $VM"
fi
}
trap cleanup EXIT
# Pane-targeted drivers. We resolve real pane IDs (APANE/BPANE) at split time
# rather than hardcoding .0/.1 — the user's tmux may set pane-base-index 1, which
# would shift the indices and silently drop sends to a nonexistent ".0".
APANE=""; BPANE=""
asay() { tmux send-keys -t "$APANE" -l "$*"; sleep 0.5; tmux send-keys -t "$APANE" Enter; sleep 0.8; }
bsay() { tmux send-keys -t "$BPANE" -l "$*"; sleep 0.5; tmux send-keys -t "$BPANE" Enter; sleep 0.8; }
acap() { tmux capture-pane -t "$APANE" -p 2>/dev/null; }
bcap() { tmux capture-pane -t "$BPANE" -p 2>/dev/null; }
await_for() { local re="$1" t="${2:-30}" i=0; while (( i < t*2 )); do acap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
bwait_for() { local re="$1" t="${2:-30}" i=0; while (( i < t*2 )); do bcap | grep -qE "$re" && return 0; sleep 0.5; ((i++)); done; return 1; }
# ---- 0. preflight -----------------------------------------------------------
step "preflight"
command -v tmux >/dev/null || { echo "tmux required"; exit 2; }
command -v VBoxManage >/dev/null || { echo "VirtualBox required"; exit 2; }
command -v xdotool >/dev/null || { echo "xdotool required"; exit 2; }
ASCIINEMA="$( [[ -x "$HOME/anaconda3/bin/asciinema" ]] && echo "$HOME/anaconda3/bin/asciinema" || command -v asciinema )"
[[ -n "$ASCIINEMA" ]] || { echo "asciinema required"; exit 2; }
[[ -x "$PY" ]] || { echo "venv python missing: $PY"; exit 2; }
VBoxManage list vms | grep -q "\"$VM\"" || { echo "VM '$VM' not registered"; exit 2; }
[[ -x "$BIN" ]] || { step "building client"; ( cd "$REPO/hh" && cargo build ) || exit 2; }
mkdir -p "$OUTDIR"
ok "tools present; VM '$VM' registered"
tmux kill-session -t "$REC_SESS" 2>/dev/null; tmux kill-session -t "$RUN_SESS" 2>/dev/null
tmux kill-session -t "$SRV_SESS" 2>/dev/null
VBoxManage controlvm "$VM" poweroff >/dev/null 2>&1 || true
rm -f "$CAST"
# ---- 1. server (not recorded) ----------------------------------------------
step "boot server :$PORT"
tmux new-session -d -s "$SRV_SESS" -x 200 -y 50 \
"cd '$REPO' && '$PY' cmd_chat.py serve 127.0.0.1 $PORT --password '$PW' --no-tls 2>&1 | tee /tmp/vbxfilm-srv.log"
sleep 3
ok "server up"
# ---- 2. two-party demo window + recorder -----------------------------------
step "open recorded window (${WIN_W}x${WIN_H}, two panes) and start asciinema"
tmux new-session -d -s "$RUN_SESS" -x "$WIN_W" -y "$WIN_H" "bash --noprofile --norc"
APANE="$(tmux list-panes -t "$RUN_SESS" -F '#{pane_id}' | head -1)" # alice
BPANE="$(tmux split-window -h -t "$APANE" -P -F '#{pane_id}' "bash --noprofile --norc")" # bob
tmux select-layout -t "$RUN_SESS" even-horizontal
sleep 0.5
for pane in "$APANE" "$BPANE"; do
tmux send-keys -t "$pane" -l "cd '$REPO'"; tmux send-keys -t "$pane" Enter
tmux send-keys -t "$pane" -l "clear"; tmux send-keys -t "$pane" Enter
done
sleep 0.5
tmux new-session -d -s "$REC_SESS" -x "$WIN_W" -y "$WIN_H" \
"'$ASCIINEMA' rec --overwrite -c 'tmux attach -t $RUN_SESS' '$CAST'"
sleep 2
ok "recording → $CAST"
# ---- 3. both parties join (alice = owner-side, bob = guest/client) ----------
asay "echo '⛧ alice — host'"; bsay "echo '⛧ bob — guest'"
sleep 0.8
asay "$BIN connect 127.0.0.1 $PORT alice --password '$PW' --no-tls"
await_for 'alice|roster|hack-house|owner' 20 && ok "alice joined" || fail "alice never joined"
bsay "$BIN connect 127.0.0.1 $PORT bob --password '$PW' --no-tls"
bwait_for 'bob|roster|hack-house' 20 && ok "bob joined" || fail "bob never joined"
sleep 1.5
# ---- 4. detect + list VMs (alice asks the room what's on the metal) ---------
step "/sbx vms — detect VirtualBox, list VMs"
asay "/sbx vms"
await_for "VirtualBox .* detected|$VM" 15 && ok "detected + listed" || fail "no detect line"
sleep 2.5
# ---- 5. the GUEST launches the shared VM through the consent gate -----------
# /sbx gui is open to ANY member (not owner-gated) — the per-client consent gate
# IS the client's permission. Bob (guest) opens it: gate → `/sbx gui yes` → boot.
# A second gate would appear if a hypervisor held VT-x; we pre-freed it so only
# the open-locally consent shows. On success bob's client broadcasts to the room.
step "/sbx gui $VM — guest (bob) consents, then boots the real VM locally"
if [[ $BOOT_VM -eq 1 ]]; then
bsay "/sbx gui $VM"
bwait_for "open .*locally|continue|cancel" 15 && ok "consent gate shown to guest" || note "no consent prompt (already running?)"
sleep 2.0
bsay "/sbx gui yes"
bwait_for "launched $VM|already running|freeing VT-x" 25 && ok "guest launch acked" || fail "no launch ack"
# The room learns the shared appliance is live: alice should see the notice.
await_for "bob opened .*$VM|open your own copy" 15 && ok "host saw the room notice" || note "host notice not seen"
sleep 1.5
# And the host can open her own copy with one command (same host → already up).
step "/sbx gui $VM — host (alice) opens her own copy"
asay "/sbx gui $VM"
await_for "already running|open .*locally|launched $VM" 15 && ok "host can open too" || note "host open not acked"
sleep 2.0
else
note "--no-vm: skipping actual boot"
fi
sleep 2
# stop the terminal recording now (the rest is the GUI grab + compose)
step "stop terminal recording"
tmux kill-session -t "$RUN_SESS" 2>/dev/null
sleep 2
[[ -s "$CAST" ]] && ok "cast: $CAST ($(du -h "$CAST" | cut -f1))" || fail "cast not written"
# ---- 6. render the terminal cast → mp4 -------------------------------------
step "render terminal cast → mp4"
bash "$VTK/bin/cast2mp4.sh" "$CAST" "$TERM_MP4" --font-size 28 --theme dracula \
&& ok "term mp4: $TERM_MP4" || fail "term render failed"
# ---- 7. grab ONLY the VirtualBox window region -----------------------------
if [[ $BOOT_VM -eq 1 ]]; then
step "capture the $VM window (${GUI_SECS}s)"
WIN=""; for i in $(seq 1 20); do
WIN="$(xdotool search --name "$VM" 2>/dev/null | tail -1)"
[[ -n "$WIN" ]] && break; sleep 1
done
if [[ -n "$WIN" ]]; then
xdotool windowactivate "$WIN" 2>/dev/null; sleep 1
# Park the window at the top-left so a tall VM window can't run off the
# bottom of the screen (x11grab refuses a capture area outside the display).
xdotool windowmove "$WIN" 0 0 2>/dev/null; sleep 0.6
eval "$(xdotool getwindowgeometry --shell "$WIN")" # sets X Y WIDTH HEIGHT
read -r SCRW SCRH < <(xdotool getdisplaygeometry)
(( X < 0 )) && X=0; (( Y < 0 )) && Y=0
MAXW=$(( SCRW - X )); MAXH=$(( SCRH - Y ))
(( WIDTH > MAXW )) && WIDTH=$MAXW
(( HEIGHT > MAXH )) && HEIGHT=$MAXH
EW=$(( WIDTH - WIDTH % 2 )); EH=$(( HEIGHT - HEIGHT % 2 ))
note "window $WIN @ ${EW}x${EH}+${X},${Y} (screen ${SCRW}x${SCRH})"
GEOM="${EW}x${EH}" OFF="${X},${Y}" bash "$VTK/bin/screen-rec.sh" "$GUI_MP4" "$GUI_SECS" \
&& ok "gui mp4: $GUI_MP4" || fail "gui capture failed"
else
fail "could not find the $VM window"
fi
fi
[[ $RENDER -eq 0 ]] && { step "result (--no-render)"; exit $FAIL; }
# ---- 8. narration (edge-tts) + subtitles -----------------------------------
step "generate TTS narration + SRT"
"$FORGE_PY" - "$NARR_MP3" "$SRT" <<'PY'
import sys, asyncio, edge_tts
from edge_tts import SubMaker
out_mp3, out_srt = sys.argv[1], sys.argv[2]
VOICE = "en-US-GuyNeural"
script = (
"Hack house now speaks VirtualBox. "
"Ask the room what's on the metal: it detects your install and lists every VM. "
"Any member can summon one — host or guest. Bob, the guest, opens it, and a consent gate asks first, because the window lands on his own machine. "
"He confirms, and a real Windows eleven lab boots locally. "
"The room sees it go live, so Alice can open her own copy with a single command. "
"One shared appliance, each party running it locally. No pixels cross the wire — same zero knowledge trust, now with a full graphical VM."
)
async def main():
sm = SubMaker()
with open(out_mp3, "wb") as f:
async for chunk in edge_tts.Communicate(script, VOICE, rate="-4%", boundary="WordBoundary").stream():
if chunk["type"] == "audio":
f.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
sm.feed(chunk)
open(out_srt, "w").write(sm.get_srt()) # edge-tts 7.x emits SRT directly
asyncio.run(main())
print("narration written")
PY
[[ -s "$NARR_MP3" ]] && ok "narration: $NARR_MP3 ($(du -h "$NARR_MP3" | cut -f1))" || fail "tts failed"
[[ -s "$SRT" ]] && ok "srt: $SRT" || note "no SRT produced"
# ---- 9. compose the visual cut (video-forge) -------------------------------
step "compose visual cut (forge)"
TERM_VID="$TERM_MP4"; GUI_VID="$GUI_MP4"
cat > "$MANIFEST" <<JSON
{
"meta": {"resolution": [1920, 1080], "fps": 30, "style": "technical", "crossfade": 0.4},
"scenes": [
{"type": "title_card", "duration": 4.0,
"headline": "VIRTUALBOX", "subhead": "share a VM — run it locally",
"accent": "$ACCENT", "transition_out": "fade"},
{"type": "video", "source": "$TERM_VID",
"transition_in": "fade", "transition_out": "fade"},
{"type": "video", "source": "$GUI_VID",
"transition_in": "fade", "transition_out": "fade"},
{"type": "result_card", "duration": 6.5,
"headline": "WHAT HAPPENED", "subhead": "both parties · client consent · zero-knowledge",
"accent": "$ACCENT",
"items": [
"detected the local VirtualBox install + listed VMs",
"any member can launch — not owner-gated",
"the guest consented (a gate, on his own machine)",
"a real Windows 11 VM booted locally in its GUI",
"the room was notified — the host can open hers too",
"one shared appliance, each party runs it locally"
],
"transition_in": "fade"}
]
}
JSON
( cd "$VTK/tools/video-forge" && "$FORGE_PY" forge.py render "$MANIFEST" --output "$CUT_MP4" ) \
&& ok "cut: $CUT_MP4" || fail "forge render failed"
# ---- 10. mux narration + burn subtitles → final ----------------------------
step "mux narration + burn subtitles → final"
if [[ -s "$CUT_MP4" && -s "$NARR_MP3" ]]; then
TMP_AV="$OUTDIR/.vbx-av.mp4"
# add the voiceover (the cut may be silent or have music; keep it low if present)
if ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 "$CUT_MP4" | grep -q .; then
ffmpeg -y -loglevel error -i "$CUT_MP4" -i "$NARR_MP3" \
-filter_complex "[0:a]volume=0.18[bg];[bg][1:a]amix=inputs=2:duration=first:dropout_transition=0[a]" \
-map 0:v -map "[a]" -c:v copy -c:a aac -movflags +faststart "$TMP_AV"
else
ffmpeg -y -loglevel error -i "$CUT_MP4" -i "$NARR_MP3" \
-map 0:v -map 1:a -c:v copy -c:a aac -shortest -movflags +faststart "$TMP_AV"
fi
if [[ -s "$SRT" ]]; then
bash "$VTK/bin/captions.sh" "$TMP_AV" "$FINAL_MP4" --srt "$SRT" --accent "$ACCENT" \
&& ok "final (subtitled): $FINAL_MP4" || { cp "$TMP_AV" "$FINAL_MP4"; note "caption burn failed; final without subs"; }
else
cp "$TMP_AV" "$FINAL_MP4"; note "no SRT; final without subs"
fi
rm -f "$TMP_AV"
else
fail "missing cut or narration; cannot finalize"
fi
# ---- summary ----------------------------------------------------------------
step "result"
if [[ $FAIL -eq 0 && -s "$FINAL_MP4" ]]; then
printf '%sFILM OK%s — %s (%s)\n' "$GREEN" "$RST" "$FINAL_MP4" "$(du -h "$FINAL_MP4" | cut -f1)"
else
printf '%sFILM FAIL%s — inspect %s\n' "$RED" "$RST" "$OUTDIR"
fi
exit $FAIL

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,39 +1431,67 @@ 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}"))?;
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 {
return Err("VirtualBox isn't installed — retry with `/sbx gui <vm> --install` (needs sudo), or run ./ensure-vbox.sh first".to_string());
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),
}
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}"))),
};
} 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()
));
}
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,
});
}
}
}
}
}
_ => app.sys(
"usage: /sbx launch [local|docker|multipass] [image] · gui <vm> · vms · stop · save [label] · load <label> · snaps",
),
@ -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]);
}
}

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 {

View File

@ -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 {

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),
)))
})