feat(sbx,ui): VM snapshot save/load + collapsible clustered help menu
- /sbx save|load|snaps: docker commit → hh-snap:<label> image that survives /sbx stop; load relaunches a fresh sandbox from it; multipass delegates to `multipass snapshot`. Local backend unsupported. - Help overlay redesigned into topical clusters (SANDBOX, AI AGENTS, PERMISSIONS, FILES, APPEARANCE, KEYS, ROSTER GLYPHS), collapsed by default; up/down highlight a cluster, left/right/Enter expand-collapse it (tmux-style), PgUp/PgDn scroll overflow, Esc closes. - docstring: example uses --model qwen2.5:3b (the locally-pulled model), not llama3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
69bce5ead8
commit
07e9c30846
|
|
@ -4,7 +4,7 @@ Examples
|
||||||
--------
|
--------
|
||||||
# local Ollama (default, recommended)
|
# local Ollama (default, recommended)
|
||||||
python -m cmd_chat.agent 127.0.0.1 3000 --name oracle \
|
python -m cmd_chat.agent 127.0.0.1 3000 --name oracle \
|
||||||
--password hunter2 --model llama3 --no-tls
|
--password hunter2 --model qwen2.5:3b --no-tls
|
||||||
|
|
||||||
# cloud, opt-in
|
# cloud, opt-in
|
||||||
python -m cmd_chat.agent 127.0.0.1 3000 --name claude \
|
python -m cmd_chat.agent 127.0.0.1 3000 --name claude \
|
||||||
|
|
|
||||||
143
hh/src/app.rs
143
hh/src/app.rs
|
|
@ -141,6 +141,11 @@ pub struct App {
|
||||||
pub show_help: bool,
|
pub show_help: bool,
|
||||||
/// Vertical scroll offset (rows) into the help overlay when it doesn't fit.
|
/// Vertical scroll offset (rows) into the help overlay when it doesn't fit.
|
||||||
pub help_scroll: u16,
|
pub help_scroll: u16,
|
||||||
|
/// Index of the currently highlighted help cluster (up/down moves it).
|
||||||
|
pub help_selected: usize,
|
||||||
|
/// Per-cluster expand/collapse state (left/right/Enter toggles); sized to the
|
||||||
|
/// cluster count when the overlay opens.
|
||||||
|
pub help_expanded: Vec<bool>,
|
||||||
/// A reconnect handshake is in flight (Ctrl-R after a disconnect).
|
/// A reconnect handshake is in flight (Ctrl-R after a disconnect).
|
||||||
pub reconnecting: bool,
|
pub reconnecting: bool,
|
||||||
/// Transient error shown as a popup over the clergy (cleared on next keypress).
|
/// Transient error shown as a popup over the clergy (cleared on next keypress).
|
||||||
|
|
@ -180,6 +185,8 @@ impl App {
|
||||||
sbx_scroll: 0,
|
sbx_scroll: 0,
|
||||||
show_help: false,
|
show_help: false,
|
||||||
help_scroll: 0,
|
help_scroll: 0,
|
||||||
|
help_selected: 0,
|
||||||
|
help_expanded: Vec::new(),
|
||||||
reconnecting: false,
|
reconnecting: false,
|
||||||
error: None,
|
error: None,
|
||||||
password: String::new(),
|
password: String::new(),
|
||||||
|
|
@ -221,6 +228,16 @@ impl App {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the help overlay fresh: first cluster highlighted, scrolled to top,
|
||||||
|
/// all clusters collapsed (the empty expand vec renders as all-collapsed and
|
||||||
|
/// is resized to the cluster count on the first navigation key).
|
||||||
|
fn open_help(&mut self) {
|
||||||
|
self.show_help = true;
|
||||||
|
self.help_scroll = 0;
|
||||||
|
self.help_selected = 0;
|
||||||
|
self.help_expanded.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Surface an error: kept in chat scrollback for history AND shown as a
|
/// Surface an error: kept in chat scrollback for history AND shown as a
|
||||||
/// popup over the clergy so it can't bleed onto / be overwritten at the
|
/// popup over the clergy so it can't bleed onto / be overwritten at the
|
||||||
/// input box. Dismissed by the next keypress.
|
/// input box. Dismissed by the next keypress.
|
||||||
|
|
@ -679,16 +696,42 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if app.show_help {
|
} else if app.show_help {
|
||||||
// While the help overlay is up, arrows / paging scroll it
|
// tmux-style nav: up/down highlight a cluster, left/right
|
||||||
// (when it's taller than the screen); only Esc closes, so
|
// (or Enter) collapse/expand it, PgUp/PgDn scroll the
|
||||||
// stray keystrokes can't dismiss a menu you're still reading.
|
// overflow, only Esc closes so stray keys can't dismiss a
|
||||||
|
// menu you're still reading.
|
||||||
|
let count = ui::help_cluster_count(&theme);
|
||||||
let max = term
|
let max = term
|
||||||
.size()
|
.size()
|
||||||
.map(|s| ui::help_max_scroll(s.width, s.height, &theme))
|
.map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
// keep the expand state sized to the clusters
|
||||||
|
if app.help_expanded.len() != count {
|
||||||
|
app.help_expanded.resize(count, false);
|
||||||
|
}
|
||||||
match k.code {
|
match k.code {
|
||||||
KeyCode::Up => app.help_scroll = app.help_scroll.saturating_sub(1),
|
KeyCode::Up => {
|
||||||
KeyCode::Down => app.help_scroll = (app.help_scroll + 1).min(max),
|
app.help_selected = app.help_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.help_selected =
|
||||||
|
(app.help_selected + 1).min(count.saturating_sub(1));
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
|
||||||
|
*e = false; // collapse the highlighted cluster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
|
||||||
|
*e = true; // reveal the highlighted cluster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||||
|
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
|
||||||
|
*e = !*e; // toggle
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10),
|
KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10),
|
||||||
KeyCode::PageDown => app.help_scroll = (app.help_scroll + 10).min(max),
|
KeyCode::PageDown => app.help_scroll = (app.help_scroll + 10).min(max),
|
||||||
KeyCode::Home => app.help_scroll = 0,
|
KeyCode::Home => app.help_scroll = 0,
|
||||||
|
|
@ -699,9 +742,14 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
}
|
}
|
||||||
_ => {} // ignore other keys so the menu stays put
|
_ => {} // ignore other keys so the menu stays put
|
||||||
}
|
}
|
||||||
|
// clamp scroll in case collapsing shrank the content
|
||||||
|
let max = term
|
||||||
|
.size()
|
||||||
|
.map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme))
|
||||||
|
.unwrap_or(0);
|
||||||
|
app.help_scroll = app.help_scroll.min(max);
|
||||||
} else if k.code == KeyCode::F(1) {
|
} else if k.code == KeyCode::F(1) {
|
||||||
app.show_help = true; // F1 from any mode
|
app.open_help(); // F1 from any mode
|
||||||
app.help_scroll = 0;
|
|
||||||
} else if k.code == KeyCode::F(2) {
|
} else if k.code == KeyCode::F(2) {
|
||||||
if app.sandbox.is_none() {
|
if app.sandbox.is_none() {
|
||||||
} else if app.can_drive() {
|
} else if app.can_drive() {
|
||||||
|
|
@ -1025,8 +1073,7 @@ fn handle_command(
|
||||||
) {
|
) {
|
||||||
let room = &session.room;
|
let room = &session.room;
|
||||||
if line == "/help" || line == "/?" {
|
if line == "/help" || line == "/?" {
|
||||||
app.show_help = true;
|
app.open_help();
|
||||||
app.help_scroll = 0;
|
|
||||||
} else if line == "/pw" || line == "/password" {
|
} else if line == "/pw" || line == "/password" {
|
||||||
// Show the room password locally (never broadcast). Handy when the
|
// Show the room password locally (never broadcast). Handy when the
|
||||||
// server's password was autogenerated and you need to read it off / share
|
// server's password was autogenerated and you need to read it off / share
|
||||||
|
|
@ -1194,7 +1241,73 @@ fn handle_command(
|
||||||
app.sys("you are not hosting a sandbox");
|
app.sys("you are not hosting a sandbox");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
|
Some("save") => {
|
||||||
|
let label = p.next().unwrap_or("snap").to_string();
|
||||||
|
if !is_snap_label(&label) {
|
||||||
|
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||||
|
} else if let Some((be, name)) = broker_meta.clone() {
|
||||||
|
app.sys(format!("saving sandbox state as '{label}'…"));
|
||||||
|
let (tx, lbl) = (app_tx.clone(), label.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label)).await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
|
||||||
|
"⛧ saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
|
||||||
|
Ok(Err(e)) => tx.send(Net::Err(format!("save failed: {e}"))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("save task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.sys("only the sandbox host can /sbx save (launch one first)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("load") => match p.next() {
|
||||||
|
None => app.sys("usage: /sbx load <label> (a docker snapshot saved via /sbx save)"),
|
||||||
|
Some(label) if !is_snap_label(label) => {
|
||||||
|
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
|
||||||
|
}
|
||||||
|
Some(label) => {
|
||||||
|
if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||||
|
app.sys("stop the current sandbox first (`/sbx stop`) before loading a snapshot");
|
||||||
|
} else if !sbx::docker_daemon_up() {
|
||||||
|
app.err("docker daemon is not running — `/sbx launch docker --start` once to boot it, then retry");
|
||||||
|
} else {
|
||||||
|
let image = format!("{}:{}", sbx::SNAP_REPO, label);
|
||||||
|
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||||
|
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
||||||
|
*launching = true;
|
||||||
|
let members: Vec<String> =
|
||||||
|
app.users.iter().map(|u| u.username.clone()).collect();
|
||||||
|
app.sys(format!("loading sandbox from {image}…"));
|
||||||
|
spawn_launch(
|
||||||
|
sbx::Backend::Docker, image, app.me.clone(), members, rows, cols,
|
||||||
|
false, pty_tx.clone(), broker_tx.clone(), app_tx.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some("snaps") | Some("snapshots") => {
|
||||||
|
let be = broker_meta
|
||||||
|
.as_ref()
|
||||||
|
.map(|(b, _)| *b)
|
||||||
|
.unwrap_or(sbx::Backend::Docker);
|
||||||
|
let (tx, name) = (app_tx.clone(), SBX_NAME.to_string());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let res = tokio::task::spawn_blocking(move || sbx::list_snapshots(be, &name)).await;
|
||||||
|
let _ = match res {
|
||||||
|
Ok(Ok(v)) if !v.is_empty() => {
|
||||||
|
tx.send(Net::Sys(format!("saved snapshots: {}", v.join(", "))))
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => tx.send(Net::Sys(
|
||||||
|
"no saved snapshots yet — `/sbx save [label]` to make one".into())),
|
||||||
|
Ok(Err(e)) => tx.send(Net::Err(format!("snaps: {e}"))),
|
||||||
|
Err(e) => tx.send(Net::Err(format!("snaps task: {e}"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => app.sys(
|
||||||
|
"usage: /sbx launch [local|docker|multipass] [image] · stop · save [label] · load <label> · snaps",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
||||||
let target = rest.trim();
|
let target = rest.trim();
|
||||||
|
|
@ -1404,6 +1517,14 @@ fn local_ollama_models() -> Result<Vec<String>, String> {
|
||||||
Ok(models)
|
Ok(models)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A safe snapshot label — compatible with both a Docker image tag and a
|
||||||
|
/// multipass snapshot name (alphanumerics plus `.`, `_`, `-`).
|
||||||
|
fn is_snap_label(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.len() <= 64
|
||||||
|
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_launch(
|
fn spawn_launch(
|
||||||
backend: sbx::Backend,
|
backend: sbx::Backend,
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,90 @@ pub fn teardown(backend: Backend, name: &str) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local Docker image repo under which sandbox snapshots are committed
|
||||||
|
/// (`hh-snap:<label>`). Owner-side only — never pushed anywhere.
|
||||||
|
pub const SNAP_REPO: &str = "hh-snap";
|
||||||
|
|
||||||
|
/// Snapshot the running sandbox's filesystem state to a named artifact on the
|
||||||
|
/// owner's machine. Blocking — run off the UI thread.
|
||||||
|
///
|
||||||
|
/// - **Docker:** `docker commit` into `hh-snap:<label>` — instant, captures the
|
||||||
|
/// live container (including provisioned users), and survives `/sbx stop`
|
||||||
|
/// because the image is independent of the container.
|
||||||
|
/// - **Multipass:** `multipass snapshot <name> --name <label>`. Multipass
|
||||||
|
/// requires the instance be **stopped** first; if it isn't, multipass's own
|
||||||
|
/// error is surfaced verbatim.
|
||||||
|
/// - **Local:** no backing VM/container, so nothing to save.
|
||||||
|
///
|
||||||
|
/// Returns a short human description of what was written on success.
|
||||||
|
pub fn save_state(backend: Backend, name: &str, label: &str) -> Result<String> {
|
||||||
|
match backend {
|
||||||
|
Backend::Docker => {
|
||||||
|
let tag = format!("{SNAP_REPO}:{label}");
|
||||||
|
let out = Command::new("docker")
|
||||||
|
.args(["commit", name, &tag])
|
||||||
|
.output()
|
||||||
|
.context("docker commit (is docker installed?)")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
anyhow::bail!("docker commit failed: {}", err.lines().last().unwrap_or("").trim());
|
||||||
|
}
|
||||||
|
Ok(format!("image {tag}"))
|
||||||
|
}
|
||||||
|
Backend::Multipass => {
|
||||||
|
let out = Command::new("multipass")
|
||||||
|
.args(["snapshot", name, "--name", label])
|
||||||
|
.output()
|
||||||
|
.context("multipass snapshot (is multipass installed?)")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
anyhow::bail!("multipass snapshot failed: {}", err.lines().last().unwrap_or("").trim());
|
||||||
|
}
|
||||||
|
Ok(format!("snapshot {name}.{label}"))
|
||||||
|
}
|
||||||
|
Backend::Local => {
|
||||||
|
anyhow::bail!("the local shell has no VM state to save — launch a docker or multipass sandbox first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List saved snapshot labels for a backend (Docker image tags under
|
||||||
|
/// `hh-snap`, or multipass snapshots of the instance). Blocking.
|
||||||
|
pub fn list_snapshots(backend: Backend, name: &str) -> Result<Vec<String>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Docker => {
|
||||||
|
let out = Command::new("docker")
|
||||||
|
.args(["images", SNAP_REPO, "--format", "{{.Tag}}"])
|
||||||
|
.output()
|
||||||
|
.context("docker images")?;
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty() && *s != "<none>")
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
Backend::Multipass => {
|
||||||
|
// `multipass list --snapshots` columns: Instance Snapshot Parent Comment.
|
||||||
|
let out = Command::new("multipass")
|
||||||
|
.args(["list", "--snapshots"])
|
||||||
|
.output()
|
||||||
|
.context("multipass list --snapshots")?;
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.skip(1) // header row
|
||||||
|
.filter_map(|l| {
|
||||||
|
let mut cols = l.split_whitespace();
|
||||||
|
let inst = cols.next()?;
|
||||||
|
let snap = cols.next()?;
|
||||||
|
(inst == name).then(|| snap.to_string())
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
Backend::Local => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the shell command for a backend, running as unix user `run_user`
|
/// Build the shell command for a backend, running as unix user `run_user`
|
||||||
/// (empty = backend default). The container/VM is already up (see `prepare`).
|
/// (empty = backend default). The container/VM is already up (see `prepare`).
|
||||||
fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
||||||
|
|
|
||||||
227
hh/src/ui.rs
227
hh/src/ui.rs
|
|
@ -45,7 +45,7 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||||
draw_input(f, rows[2], app, theme);
|
draw_input(f, rows[2], app, theme);
|
||||||
|
|
||||||
if app.show_help {
|
if app.show_help {
|
||||||
draw_help(f, f.area(), theme, app.help_scroll);
|
draw_help(f, f.area(), app, theme);
|
||||||
}
|
}
|
||||||
if let Some(msg) = &app.error {
|
if let Some(msg) = &app.error {
|
||||||
draw_error(f, f.area(), theme, msg);
|
draw_error(f, f.area(), theme, msg);
|
||||||
|
|
@ -116,33 +116,29 @@ fn help_popup(area: Rect) -> Rect {
|
||||||
|
|
||||||
/// Largest vertical scroll offset for the help overlay given the full terminal
|
/// Largest vertical scroll offset for the help overlay given the full terminal
|
||||||
/// area: total wrapped content rows minus the visible viewport (0 if it all fits).
|
/// area: total wrapped content rows minus the visible viewport (0 if it all fits).
|
||||||
pub fn help_max_scroll(width: u16, height: u16, theme: &Theme) -> u16 {
|
pub fn help_max_scroll(width: u16, height: u16, app: &App, theme: &Theme) -> u16 {
|
||||||
let w = help_popup(Rect::new(0, 0, width, height));
|
let w = help_popup(Rect::new(0, 0, width, height));
|
||||||
let inner_w = w.width.saturating_sub(2);
|
let inner_w = w.width.saturating_sub(2);
|
||||||
let inner_h = w.height.saturating_sub(2);
|
let inner_h = w.height.saturating_sub(2);
|
||||||
if inner_w == 0 {
|
if inner_w == 0 {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let total = Paragraph::new(help_lines(theme))
|
let total = Paragraph::new(help_render_lines(app, theme))
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.line_count(inner_w) as u16;
|
.line_count(inner_w) as u16;
|
||||||
total.saturating_sub(inner_h)
|
total.saturating_sub(inner_h)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn help_lines(theme: &Theme) -> Vec<Line<'static>> {
|
/// One named, collapsible group of help entries.
|
||||||
let acc = Style::default()
|
struct HelpCluster {
|
||||||
.fg(theme.accent)
|
title: &'static str,
|
||||||
.add_modifier(Modifier::BOLD);
|
items: Vec<(String, String)>,
|
||||||
let key = Style::default().fg(theme.title);
|
}
|
||||||
let dim = Style::default().fg(theme.system);
|
|
||||||
let kv = |k: &str, v: &str| {
|
/// The help content, grouped into topical clusters. Order here is the order the
|
||||||
Line::from(vec![
|
/// up/down highlight walks through.
|
||||||
Span::styled(format!(" {k:<26}"), key),
|
fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||||
Span::styled(v.to_string(), dim),
|
let kv = |k: &str, v: &str| (k.to_string(), v.to_string());
|
||||||
])
|
|
||||||
};
|
|
||||||
let sig = theme.sigil.clone();
|
|
||||||
let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc));
|
|
||||||
// List whatever vestments are actually installed, so new themes show up here
|
// List whatever vestments are actually installed, so new themes show up here
|
||||||
// automatically (church · neon · crypt · blush · matrix · wraith · …).
|
// automatically (church · neon · crypt · blush · matrix · wraith · …).
|
||||||
let theme_help = format!(
|
let theme_help = format!(
|
||||||
|
|
@ -150,95 +146,126 @@ fn help_lines(theme: &Theme) -> Vec<Line<'static>> {
|
||||||
Theme::available().join(" · ")
|
Theme::available().join(" · ")
|
||||||
);
|
);
|
||||||
vec![
|
vec![
|
||||||
head("COMMANDS (type in the input bar)"),
|
HelpCluster {
|
||||||
kv(
|
title: "SANDBOX",
|
||||||
"/sbx launch [backend]",
|
items: vec![
|
||||||
"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("/drive", "type into the shared shell (Esc releases)"),
|
kv("/sbx load <label>", "launch a fresh sandbox from a saved snapshot"),
|
||||||
kv(
|
kv("/sbx snaps", "list saved snapshots"),
|
||||||
"/ai start [model|profile]",
|
kv("/drive · F2", "type into the shared shell (Esc releases)"),
|
||||||
"spawn an AI agent (ollama model tag, or a models.toml profile)",
|
],
|
||||||
),
|
},
|
||||||
kv("/ai stop", "dismiss the agent you started"),
|
HelpCluster {
|
||||||
kv(
|
title: "AI AGENTS",
|
||||||
"/ai <question>",
|
items: vec![
|
||||||
"ask an AI agent in the room (/ai <name> <q> if many)",
|
kv("/ai start [model|profile]", "spawn an agent (ollama tag or models.toml profile)"),
|
||||||
),
|
kv("/ai stop", "dismiss the agent you started"),
|
||||||
kv("/ai list", "list AI agents present + their provider/model"),
|
kv("/ai <question>", "ask an agent in the room (/ai <name> <q> if many)"),
|
||||||
kv("/ai models", "show models the active agent's backend can serve"),
|
kv("/ai list", "list AI agents present + their provider/model"),
|
||||||
kv(
|
kv("/ai models", "show models the active agent's backend can serve"),
|
||||||
"/grant <user>",
|
],
|
||||||
"let a member drive the shell (owner)",
|
},
|
||||||
),
|
HelpCluster {
|
||||||
kv(
|
title: "PERMISSIONS (owner)",
|
||||||
"/revoke <user>",
|
items: vec![
|
||||||
"take back drive permission (owner)",
|
kv("/grant <user>", "let a member drive the shell"),
|
||||||
),
|
kv("/revoke <user>", "take back drive permission"),
|
||||||
kv(
|
kv("/sudo <user>", "delegate VM superuser (real sudo)"),
|
||||||
"/sudo <user>",
|
kv("/unsudo <user>", "revoke VM superuser"),
|
||||||
"delegate VM superuser (real sudo) (owner)",
|
],
|
||||||
),
|
},
|
||||||
kv(
|
HelpCluster {
|
||||||
"/unsudo <user>",
|
title: "FILES",
|
||||||
"revoke VM superuser (owner)",
|
items: vec![
|
||||||
),
|
kv("/send <file>", "offer a file to the room"),
|
||||||
kv("/send <file>", "offer a file to the room"),
|
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
||||||
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
|
kv("/accept · /reject", "respond to an incoming file offer"),
|
||||||
kv("/accept · /reject", "respond to an incoming file offer"),
|
],
|
||||||
kv("/theme [name]", &theme_help),
|
},
|
||||||
kv("/pw", "show this room's password (local only)"),
|
HelpCluster {
|
||||||
kv("/help", "show / hide this menu"),
|
title: "APPEARANCE",
|
||||||
Line::from(""),
|
items: vec![
|
||||||
head("KEYS"),
|
kv("/theme [name]", &theme_help),
|
||||||
kv("Enter", "send chat message"),
|
kv("/theme save [name]", "keep the vestment you're wearing for reuse"),
|
||||||
kv("F1 · /help", "toggle this help (any key closes it)"),
|
kv("Ctrl+Alt+P · /theme random", "conjure a random vestment (palette + sigil)"),
|
||||||
kv("F2 · /drive", "take the shell · Esc releases it"),
|
],
|
||||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
},
|
||||||
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
HelpCluster {
|
||||||
kv(
|
title: "KEYS",
|
||||||
"PgUp / PgDn (driving)",
|
items: vec![
|
||||||
"scroll the sandbox terminal's scrollback",
|
kv("Enter", "send chat message"),
|
||||||
),
|
kv("F1 · /help", "toggle this help"),
|
||||||
kv(
|
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||||
"Up / Down · wheel",
|
kv("PgUp / PgDn", "scroll chat · Home/End = oldest/live"),
|
||||||
"scroll the sandbox terminal (mouse works while driving)",
|
kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
|
||||||
),
|
kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
|
||||||
kv(
|
kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
|
||||||
"Ctrl-R (when closed)",
|
kv("/pw", "show this room's password (local only)"),
|
||||||
"reconnect to the house after a drop / AFK",
|
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
||||||
),
|
],
|
||||||
kv(
|
},
|
||||||
"Ctrl+Alt+P · /theme random",
|
HelpCluster {
|
||||||
"conjure a random vestment (new palette + sigil)",
|
title: "ROSTER GLYPHS",
|
||||||
),
|
items: vec![kv(
|
||||||
kv(
|
&format!("{} owner ⚡ sudoer", theme.sigil),
|
||||||
"/theme save [name]",
|
"◆ may drive • member",
|
||||||
"keep the vestment you're wearing for reuse",
|
)],
|
||||||
),
|
},
|
||||||
kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
|
|
||||||
Line::from(""),
|
|
||||||
head("ROSTER GLYPHS"),
|
|
||||||
kv(
|
|
||||||
&format!("{} owner ⚡ sudoer", theme.sigil),
|
|
||||||
"◆ may drive • member",
|
|
||||||
),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
" malware bless · ↑/↓ · PgUp/PgDn scroll · Esc closes",
|
|
||||||
Style::default()
|
|
||||||
.fg(theme.dim)
|
|
||||||
.add_modifier(Modifier::ITALIC),
|
|
||||||
)),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_help(f: &mut Frame, area: Rect, theme: &Theme, scroll: u16) {
|
/// Number of help clusters — used to size/clamp the navigation state.
|
||||||
|
pub fn help_cluster_count(theme: &Theme) -> usize {
|
||||||
|
help_clusters(theme).len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten the clusters into rendered lines given the current collapse state and
|
||||||
|
/// highlighted cluster. Shared by `draw_help` and `help_max_scroll` so the
|
||||||
|
/// 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 sel = Style::default()
|
||||||
|
.fg(theme.bg)
|
||||||
|
.bg(theme.accent)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let key = Style::default().fg(theme.title);
|
||||||
|
let dim = Style::default().fg(theme.system);
|
||||||
|
let sig = theme.sigil.clone();
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
for (i, c) in clusters.iter().enumerate() {
|
||||||
|
let expanded = app.help_expanded.get(i).copied().unwrap_or(false);
|
||||||
|
let marker = if expanded { "▾" } else { "▸" };
|
||||||
|
let style = if i == app.help_selected { sel } else { acc };
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("{sig} {marker} {} ({})", c.title, c.items.len()),
|
||||||
|
style,
|
||||||
|
)));
|
||||||
|
if expanded {
|
||||||
|
for (k, v) in &c.items {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {k:<26}"), key),
|
||||||
|
Span::styled(v.clone(), dim),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ↑/↓ select · ←/→ or Enter expand · PgUp/PgDn scroll · Esc closes",
|
||||||
|
Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
|
||||||
let w = help_popup(area);
|
let w = help_popup(area);
|
||||||
let inner_w = w.width.saturating_sub(2);
|
let inner_w = w.width.saturating_sub(2);
|
||||||
let inner_h = w.height.saturating_sub(2);
|
let inner_h = w.height.saturating_sub(2);
|
||||||
let para = Paragraph::new(help_lines(theme)).wrap(Wrap { trim: false });
|
let scroll = app.help_scroll;
|
||||||
|
let para = Paragraph::new(help_render_lines(app, theme)).wrap(Wrap { trim: false });
|
||||||
// Clamp so you can never scroll past the last line; show a hint when there's
|
// Clamp so you can never scroll past the last line; show a hint when there's
|
||||||
// more below the fold.
|
// more below the fold.
|
||||||
let max = (para.line_count(inner_w) as u16).saturating_sub(inner_h);
|
let max = (para.line_count(inner_w) as u16).saturating_sub(inner_h);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user