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:
parent
01e607dced
commit
5676216a2f
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -17,6 +17,17 @@ secured_console_chat.egg-info/
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
.idea/
|
.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
|
# Project scratch
|
||||||
/i-try/
|
/i-try/
|
||||||
test_rsa.py
|
test_rsa.py
|
||||||
|
|
|
||||||
44
README.MD
44
README.MD
|
|
@ -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
|
- **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
|
- **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
|
- **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
|
- **Snapshot save/load** — freeze a sandbox to a named snapshot and restore it later (`/sbx save` · `/sbx load` · `/sbx snaps`)
|
||||||
- **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
|
- **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
|
- **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
|
- **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
|
## 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 |
|
| `/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 launch [local\|docker\|multipass] [image]` | Summon the shared sandbox |
|
||||||
| `/sbx stop` | Tear down the sandbox you host |
|
| `/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) |
|
| `/drive` · `F2` | Take the shared shell (`Esc` releases) |
|
||||||
| `/grant <user>` · `/revoke <user>` | Owner: delegate/withdraw drive |
|
| `/grant <user>` · `/revoke <user>` | Owner: delegate/withdraw drive |
|
||||||
| `/sudo <user>` · `/unsudo <user>` | Owner: delegate/withdraw VM superuser |
|
| `/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).
|
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
|
### Driving the shell
|
||||||
|
|
||||||
The shared terminal is **watch-by-default**: everyone sees the live output, but
|
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
|
"may type" and "may run privileged commands" are independent and enforced by
|
||||||
the OS itself.
|
the OS itself.
|
||||||
|
|
||||||
The roster shows each member's status: owner, sudoer (⚡), driver (◆), or
|
The roster shows each member's status with **stacking badges**: host (the
|
||||||
member (•).
|
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
|
### Sharing files & directories
|
||||||
|
|
||||||
|
|
@ -257,10 +281,12 @@ directly:
|
||||||
|
|
||||||
### Themes (vestments)
|
### Themes (vestments)
|
||||||
|
|
||||||
Three bundled themes — `crypt` (default, neutral monochrome), `church` (neon),
|
Seven bundled themes — `crypt` (default, neutral monochrome, `✝` sigil),
|
||||||
and `neon`. Switch live with `/theme <name>`, list them with bare `/theme`, or
|
`church`, `neon`, `blush`, `matrix`, `wraith`, and `goldcrypt`. Switch live with
|
||||||
load your own TOML at launch with `--theme <path>` (see `hh/themes/`). Each
|
`/theme <name>`, list them with bare `/theme`, roll a fresh randomized vestment
|
||||||
theme defines its own sigil, colours, and roster width.
|
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
|
### Staying connected
|
||||||
|
|
||||||
|
|
|
||||||
297
hh/film-virtualbox.sh
Executable file
297
hh/film-virtualbox.sh
Executable 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
|
||||||
496
hh/src/app.rs
496
hh/src/app.rs
|
|
@ -37,6 +37,18 @@ pub struct ChatLine {
|
||||||
pub system: bool,
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
|
@ -107,6 +119,13 @@ pub enum Net {
|
||||||
/// A local system notice produced off-thread (e.g. async Ollama probe).
|
/// A local system notice produced off-thread (e.g. async Ollama probe).
|
||||||
Sys(String),
|
Sys(String),
|
||||||
Err(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,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +134,33 @@ pub struct SbxView {
|
||||||
pub backend: String,
|
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`).
|
/// Display handle the summoned Python agent joins under (see `spawn_agent`).
|
||||||
const AGENT_NAME: &str = "oracle";
|
const AGENT_NAME: &str = "oracle";
|
||||||
|
|
||||||
|
|
@ -132,6 +178,9 @@ pub struct App {
|
||||||
/// Members whose VM unix account has sudo (superuser). Always includes owner.
|
/// Members whose VM unix account has sudo (superuser). Always includes owner.
|
||||||
pub sudoers: std::collections::HashSet<String>,
|
pub sudoers: std::collections::HashSet<String>,
|
||||||
pub pending_offer: Option<ft::Offer>,
|
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>,
|
transfers: HashMap<String, Transfer>,
|
||||||
/// Chat scrollback: lines scrolled up from the live bottom (0 = following).
|
/// Chat scrollback: lines scrolled up from the live bottom (0 = following).
|
||||||
pub chat_scroll: usize,
|
pub chat_scroll: usize,
|
||||||
|
|
@ -180,6 +229,7 @@ impl App {
|
||||||
drivers: std::collections::HashSet::new(),
|
drivers: std::collections::HashSet::new(),
|
||||||
sudoers: std::collections::HashSet::new(),
|
sudoers: std::collections::HashSet::new(),
|
||||||
pending_offer: None,
|
pending_offer: None,
|
||||||
|
pending_vm: None,
|
||||||
transfers: HashMap::new(),
|
transfers: HashMap::new(),
|
||||||
chat_scroll: 0,
|
chat_scroll: 0,
|
||||||
sbx_scroll: 0,
|
sbx_scroll: 0,
|
||||||
|
|
@ -219,6 +269,35 @@ impl App {
|
||||||
self.drivers.contains(&self.me)
|
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>) {
|
fn sys(&mut self, text: impl Into<String>) {
|
||||||
self.push_line(ChatLine {
|
self.push_line(ChatLine {
|
||||||
ts: String::new(),
|
ts: String::new(),
|
||||||
|
|
@ -353,6 +432,14 @@ impl App {
|
||||||
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
||||||
Net::Sys(t) => self.sys(t),
|
Net::Sys(t) => self.sys(t),
|
||||||
Net::Err(t) => self.err(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 => {
|
Net::Closed => {
|
||||||
self.connected = false;
|
self.connected = false;
|
||||||
self.sys("connection closed — press Ctrl-R to reconnect");
|
self.sys("connection closed — press Ctrl-R to reconnect");
|
||||||
|
|
@ -1015,9 +1102,8 @@ async fn writer_task(
|
||||||
biased;
|
biased;
|
||||||
// Swap in a reconnect's fresh sink before draining more frames.
|
// Swap in a reconnect's fresh sink before draining more frames.
|
||||||
new_sink = sink_rx.recv() => {
|
new_sink = sink_rx.recv() => {
|
||||||
match new_sink {
|
if let Some(s) = new_sink {
|
||||||
Some(s) => sink = s,
|
sink = s;
|
||||||
None => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
msg = out_rx.recv() => {
|
msg = out_rx.recv() => {
|
||||||
|
|
@ -1094,13 +1180,20 @@ fn handle_command(
|
||||||
} else if name == "random" {
|
} else if name == "random" {
|
||||||
// Same as Ctrl+Alt+P — roll a fresh procedural vestment.
|
// Same as Ctrl+Alt+P — roll a fresh procedural vestment.
|
||||||
*theme = Theme::random();
|
*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 ") {
|
} else if name == "save" || name.starts_with("save ") {
|
||||||
// Persist the vestment you're currently wearing (e.g. a `random`
|
// Persist the vestment you're currently wearing (e.g. a `random`
|
||||||
// roll you like) to themes/<slug>.toml so it sticks around. Bare
|
// roll you like) to themes/<slug>.toml so it sticks around. Bare
|
||||||
// `/theme save` reuses the theme's own generated name.
|
// `/theme save` reuses the theme's own generated name.
|
||||||
let want = name[4..].trim();
|
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) {
|
match theme.save(&want) {
|
||||||
Ok(slug) => app.sys(format!(
|
Ok(slug) => app.sys(format!(
|
||||||
"{} saved vestment '{slug}' — re-don it anytime with /theme {slug}",
|
"{} saved vestment '{slug}' — re-don it anytime with /theme {slug}",
|
||||||
|
|
@ -1338,36 +1431,64 @@ fn handle_command(
|
||||||
}
|
}
|
||||||
Some("gui") => {
|
Some("gui") => {
|
||||||
let gargs: Vec<&str> = p.collect();
|
let gargs: Vec<&str> = p.collect();
|
||||||
let install = gargs.iter().any(|a| *a == "--install");
|
let first = gargs.iter().copied().find(|a| !a.starts_with('-'));
|
||||||
let name = gargs
|
let is_yes = matches!(first, Some("yes" | "y" | "go" | "confirm" | "ok"));
|
||||||
.iter()
|
let is_no = matches!(first, Some("no" | "n" | "cancel" | "abort" | "stop"));
|
||||||
.copied()
|
if is_no {
|
||||||
.find(|a| !a.starts_with('-'))
|
// A `/sbx gui cancel` at any gate: drop the launch, no side
|
||||||
.map(str::to_string);
|
// effects (nothing was stopped or installed yet).
|
||||||
match name {
|
if app.pending_vm.take().is_some() {
|
||||||
None => app.sys("usage: /sbx gui <vm> [--install] (list VMs with /sbx vms)"),
|
app.sys("cancelled — no VM launched, nothing stopped or installed");
|
||||||
Some(vm) => {
|
} else {
|
||||||
app.sys(format!("launching {vm} in the VirtualBox GUI…"));
|
app.sys("nothing to cancel");
|
||||||
let tx = app_tx.clone();
|
}
|
||||||
tokio::spawn(async move {
|
} else if is_yes {
|
||||||
let res = tokio::task::spawn_blocking(move || {
|
// Advance the gauntlet one gate.
|
||||||
if !sbx::vbox_installed() {
|
match app.pending_vm.take() {
|
||||||
if install {
|
None => app.sys("no VM launch is waiting — start one with `/sbx gui <vm>`"),
|
||||||
sbx::ensure_vbox_install()
|
Some(pend) => advance_vm(pend, app, app_tx, out_tx, room),
|
||||||
.map_err(|e| format!("install failed: {e}"))?;
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err("VirtualBox isn't installed — retry with `/sbx gui <vm> --install` (needs sudo), or run ./ensure-vbox.sh first".to_string());
|
// 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())
|
if !installed {
|
||||||
})
|
warn.push_str(" (VirtualBox isn't installed yet.)");
|
||||||
.await;
|
}
|
||||||
let _ = match res {
|
app.sys(format!(
|
||||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
|
"open ‘{vm}’ locally? a VirtualBox window will launch on YOUR machine.{warn} → `/sbx gui yes` to continue · `/sbx gui cancel` to abort"
|
||||||
Ok(Err(e)) => tx.send(Net::Err(e)),
|
));
|
||||||
Err(e) => tx.send(Net::Err(format!("gui task: {e}"))),
|
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 {
|
fn is_snap_label(s: &str) -> bool {
|
||||||
!s.is_empty()
|
!s.is_empty()
|
||||||
&& s.len() <= 64
|
&& 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
@ -1712,7 +1948,10 @@ fn spawn_agent(
|
||||||
cmd.arg("--profile").arg(p);
|
cmd.arg("--profile").arg(p);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
cmd.arg("--provider").arg("ollama").arg("--model").arg(model);
|
cmd.arg("--provider")
|
||||||
|
.arg("ollama")
|
||||||
|
.arg("--model")
|
||||||
|
.arg(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd.stdin(Stdio::null())
|
cmd.stdin(Stdio::null())
|
||||||
|
|
@ -1733,9 +1972,132 @@ fn spawn_agent(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
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.
|
/// `drain_ready` pulls a bounded burst in FIFO order and stops at the cap.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn drain_ready_is_fifo_and_capped() {
|
async fn drain_ready_is_fifo_and_capped() {
|
||||||
|
|
@ -1789,6 +2151,64 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(saw_chat, "chat frame must be observed");
|
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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,9 @@ fn main() -> Result<()> {
|
||||||
match net::authenticate(&ip, port, &name, &password, no_tls, insecure) {
|
match net::authenticate(&ip, port, &name, &password, no_tls, insecure) {
|
||||||
Ok(s) => break s,
|
Ok(s) => break s,
|
||||||
Err(e) if interactive => {
|
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()?;
|
name = prompt_handle()?;
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
|
|
@ -168,7 +170,7 @@ fn main() -> Result<()> {
|
||||||
fn prompt_handle() -> Result<String> {
|
fn prompt_handle() -> Result<String> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
loop {
|
loop {
|
||||||
print!("⛧ choose your handle: ");
|
print!("✝ choose your handle: ");
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
if std::io::stdin().read_line(&mut s)? == 0 {
|
if std::io::stdin().read_line(&mut s)? == 0 {
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ impl Theme {
|
||||||
pub fn random() -> Theme {
|
pub fn random() -> Theme {
|
||||||
let mut r = Rng::seeded();
|
let mut r = Rng::seeded();
|
||||||
let base = r.range(0.0, 360.0); // base hue for the surface/ink family
|
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 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
|
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.
|
/// the file, the `name` field, and the `/theme` argument all agree.
|
||||||
pub fn save(&self, name: &str) -> anyhow::Result<String> {
|
pub fn save(&self, name: &str) -> anyhow::Result<String> {
|
||||||
let slug = slugify(name);
|
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();
|
let mut t = self.clone();
|
||||||
t.name = slug.clone();
|
t.name = slug.clone();
|
||||||
let path = format!("{THEMES_DIR}/{slug}.toml");
|
let path = format!("{THEMES_DIR}/{slug}.toml");
|
||||||
let body = toml::to_string_pretty(&t)
|
let body =
|
||||||
.with_context(|| format!("serialize vestment '{slug}'"))?;
|
toml::to_string_pretty(&t).with_context(|| format!("serialize vestment '{slug}'"))?;
|
||||||
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
|
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
|
||||||
Ok(slug)
|
Ok(slug)
|
||||||
}
|
}
|
||||||
|
|
@ -165,18 +168,30 @@ fn slugify(name: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Occult glyphs the randomizer can stamp as the title sigil.
|
/// 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.
|
/// Arcane name fragments — `<adj>-<noun>` makes a memorable vestment name.
|
||||||
const NAME_ADJ: [&str; 16] = [
|
const NAME_ADJ: [&str; 16] = [
|
||||||
"ashen", "umbral", "votive", "hollow", "gilded", "wraith", "septic", "occult",
|
"ashen", "umbral", "votive", "hollow", "gilded", "wraith", "septic", "occult", "molten",
|
||||||
"molten", "veiled", "sallow", "rotting", "sacred", "cinder", "obsidian", "vesper",
|
"veiled", "sallow", "rotting", "sacred", "cinder", "obsidian", "vesper",
|
||||||
];
|
];
|
||||||
const NAME_NOUN: [&str; 16] = [
|
const NAME_NOUN: [&str; 16] = [
|
||||||
"reliquary", "ossuary", "vestment", "censer", "shroud", "chancel", "crypt", "sepulcher",
|
"reliquary",
|
||||||
"litany", "chalice", "rood", "narthex", "thurible", "psalter", "ossein", "vigil",
|
"ossuary",
|
||||||
|
"vestment",
|
||||||
|
"censer",
|
||||||
|
"shroud",
|
||||||
|
"chancel",
|
||||||
|
"crypt",
|
||||||
|
"sepulcher",
|
||||||
|
"litany",
|
||||||
|
"chalice",
|
||||||
|
"rood",
|
||||||
|
"narthex",
|
||||||
|
"thurible",
|
||||||
|
"psalter",
|
||||||
|
"ossein",
|
||||||
|
"vigil",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Convert HSV (h in degrees 0–360, s/v in 0–1) to an 8-bit-per-channel RGB
|
/// Convert HSV (h in degrees 0–360, s/v in 0–1) to an 8-bit-per-channel RGB
|
||||||
|
|
@ -272,7 +287,11 @@ roster_width = 24
|
||||||
let t = Theme::random();
|
let t = Theme::random();
|
||||||
// Name is `<adj>-<noun>` and the sigil is one of the occult glyphs.
|
// 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!(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);
|
assert_eq!(t.roster_width, 22);
|
||||||
// Surface must stay dark enough to read light ink against it.
|
// Surface must stay dark enough to read light ink against it.
|
||||||
if let Color::Rgb(r, g, b) = t.bg {
|
if let Color::Rgb(r, g, b) = t.bg {
|
||||||
|
|
|
||||||
174
hh/src/ui.rs
174
hh/src/ui.rs
|
|
@ -1,6 +1,6 @@
|
||||||
//! ratatui rendering — top bar, chat, roster, input.
|
//! ratatui rendering — top bar, chat, roster, input.
|
||||||
|
|
||||||
use crate::app::{App, ChatLine};
|
use crate::app::{App, ChatLine, Role};
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
|
|
@ -149,36 +149,85 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||||
HelpCluster {
|
HelpCluster {
|
||||||
title: "SANDBOX",
|
title: "SANDBOX",
|
||||||
items: vec![
|
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 stop", "tear down the sandbox (purges the VM)"),
|
||||||
kv("/sbx save [label]", "snapshot state (docker image; survives stop)"),
|
kv(
|
||||||
kv("/sbx load <label>", "launch a fresh sandbox from a saved snapshot"),
|
"/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 snaps", "list saved snapshots"),
|
||||||
kv("/sbx vms", "list local VirtualBox VMs"),
|
kv(
|
||||||
kv("/sbx gui <vm>", "boot a VirtualBox VM locally in its GUI"),
|
"/drive · F2",
|
||||||
kv("/drive · F2", "type into the shared shell (Esc releases)"),
|
"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 {
|
HelpCluster {
|
||||||
title: "AI AGENTS",
|
title: "AI AGENTS",
|
||||||
items: vec![
|
items: vec![
|
||||||
kv("/ai start [model|profile]", "spawn an agent (ollama tag or models.toml profile)"),
|
kv(
|
||||||
kv("/ai start <model> allow", "spawn + auto-grant the agent sandbox drive"),
|
"/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 stop", "dismiss the agent you started"),
|
||||||
kv("/ai <question>", "ask an agent in the room (/ai <name> <q> if many)"),
|
kv(
|
||||||
kv("/ai <name> !<task>", "have a granted agent run a task in the sandbox"),
|
"/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 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 {
|
HelpCluster {
|
||||||
title: "PERMISSIONS (owner)",
|
title: "PERMISSIONS (owner)",
|
||||||
items: vec![
|
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("/revoke <user|agent>", "take back sandbox drive permission"),
|
||||||
kv("/sudo <user>", "delegate VM superuser (real sudo)"),
|
kv("/sudo <user>", "delegate VM superuser (real sudo)"),
|
||||||
kv("/unsudo <user>", "revoke VM superuser"),
|
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 {
|
HelpCluster {
|
||||||
|
|
@ -193,8 +242,14 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||||
title: "APPEARANCE",
|
title: "APPEARANCE",
|
||||||
items: vec![
|
items: vec![
|
||||||
kv("/theme [name]", &theme_help),
|
kv("/theme [name]", &theme_help),
|
||||||
kv("/theme save [name]", "keep the vestment you're wearing for reuse"),
|
kv(
|
||||||
kv("Ctrl+Alt+P · /theme random", "conjure a random vestment (palette + sigil)"),
|
"/theme save [name]",
|
||||||
|
"keep the vestment you're wearing for reuse",
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"Ctrl+Alt+P · /theme random",
|
||||||
|
"conjure a random vestment (palette + sigil)",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HelpCluster {
|
HelpCluster {
|
||||||
|
|
@ -204,19 +259,33 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||||
kv("F1 · /help", "toggle this help"),
|
kv("F1 · /help", "toggle this help"),
|
||||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||||
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
||||||
kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
|
kv(
|
||||||
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
|
"PgUp / PgDn (driving)",
|
||||||
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
|
"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("/pw", "show this room's password (local only)"),
|
||||||
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HelpCluster {
|
HelpCluster {
|
||||||
title: "ROSTER GLYPHS",
|
title: "ROSTER GLYPHS (badges stack)",
|
||||||
items: vec![kv(
|
items: vec![
|
||||||
&format!("{} owner ⚡ sudoer", theme.sigil),
|
kv(
|
||||||
"◆ may drive • member",
|
&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.
|
/// scroll math always matches what's painted.
|
||||||
fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
|
fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
|
||||||
let clusters = help_clusters(theme);
|
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()
|
let sel = Style::default()
|
||||||
.fg(theme.bg)
|
.fg(theme.bg)
|
||||||
.bg(theme.accent)
|
.bg(theme.accent)
|
||||||
|
|
@ -260,7 +331,9 @@ fn help_render_lines(app: &App, theme: &Theme) -> Vec<Line<'static>> {
|
||||||
}
|
}
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
" ↑/↓ select · ←/→ or Enter expand · PgUp/PgDn scroll · Esc closes",
|
" ↑/↓ 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
|
lines
|
||||||
}
|
}
|
||||||
|
|
@ -375,8 +448,12 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
|
||||||
} else {
|
} else {
|
||||||
theme.other
|
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![
|
Line::from(vec![
|
||||||
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
Span::styled(format!("{} ", l.ts), Style::default().fg(theme.dim)),
|
||||||
|
Span::styled(format!("{badges} "), Style::default().fg(theme.dim)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
l.username.clone(),
|
l.username.clone(),
|
||||||
Style::default().fg(name_color).add_modifier(Modifier::BOLD),
|
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![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{name} "),
|
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("⠿ ", Style::default().fg(theme.dim)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
text.as_str(),
|
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);
|
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) {
|
fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| {
|
.map(|u| {
|
||||||
let me = u.username == app.me;
|
let me = u.username == app.me;
|
||||||
// <sigil> owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member
|
// Stacked badges: <sigil> host · ⚡ sudoer · ◆ driver · • member.
|
||||||
let owner = app.owner.as_deref() == Some(u.username.as_str());
|
let badges = role_badges(app, &u.username, theme);
|
||||||
let mark = if owner {
|
|
||||||
theme.sigil.as_str()
|
|
||||||
} else if app.sudoers.contains(&u.username) {
|
|
||||||
"⚡"
|
|
||||||
} else if app.drivers.contains(&u.username) {
|
|
||||||
"◆"
|
|
||||||
} else {
|
|
||||||
"•"
|
|
||||||
};
|
|
||||||
let color = if me { theme.roster_me } else { theme.other };
|
let color = if me { theme.roster_me } else { theme.other };
|
||||||
ListItem::new(Line::from(Span::styled(
|
ListItem::new(Line::from(Span::styled(
|
||||||
format!(" {mark} {}", u.username),
|
format!(" {badges} {}", u.username),
|
||||||
Style::default().fg(color),
|
Style::default().fg(color),
|
||||||
)))
|
)))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user