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:
leetcrypt 2026-06-02 23:03:00 -07:00
parent 69bce5ead8
commit 07e9c30846
4 changed files with 344 additions and 112 deletions

View File

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

View File

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

View File

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

View File

@ -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("/drive", "type into the shared shell (Esc releases)"), kv("/sbx save [label]", "snapshot state (docker image; survives stop)"),
kv( kv("/sbx load <label>", "launch a fresh sandbox from a saved snapshot"),
"/ai start [model|profile]", kv("/sbx snaps", "list saved snapshots"),
"spawn an AI agent (ollama model tag, or a models.toml profile)", kv("/drive · F2", "type into the shared shell (Esc releases)"),
), ],
},
HelpCluster {
title: "AI AGENTS",
items: vec![
kv("/ai start [model|profile]", "spawn an agent (ollama tag or models.toml profile)"),
kv("/ai stop", "dismiss the agent you started"), kv("/ai stop", "dismiss the agent you started"),
kv( kv("/ai <question>", "ask an agent in the room (/ai <name> <q> if many)"),
"/ai <question>",
"ask an AI agent in the room (/ai <name> <q> if many)",
),
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"),
kv( ],
"/grant <user>", },
"let a member drive the shell (owner)", HelpCluster {
), title: "PERMISSIONS (owner)",
kv( items: vec![
"/revoke <user>", kv("/grant <user>", "let a member drive the shell"),
"take back drive permission (owner)", kv("/revoke <user>", "take back drive permission"),
), kv("/sudo <user>", "delegate VM superuser (real sudo)"),
kv( kv("/unsudo <user>", "revoke VM superuser"),
"/sudo <user>", ],
"delegate VM superuser (real sudo) (owner)", },
), HelpCluster {
kv( title: "FILES",
"/unsudo <user>", items: vec![
"revoke VM superuser (owner)",
),
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"),
],
},
HelpCluster {
title: "APPEARANCE",
items: vec![
kv("/theme [name]", &theme_help), kv("/theme [name]", &theme_help),
kv("/pw", "show this room's password (local only)"), kv("/theme save [name]", "keep the vestment you're wearing for reuse"),
kv("/help", "show / hide this menu"), kv("Ctrl+Alt+P · /theme random", "conjure a random vestment (palette + sigil)"),
Line::from(""), ],
head("KEYS"), },
HelpCluster {
title: "KEYS",
items: vec![
kv("Enter", "send chat message"), kv("Enter", "send chat message"),
kv("F1 · /help", "toggle this help (any key closes it)"), kv("F1 · /help", "toggle this help"),
kv("F2 · /drive", "take the shell · Esc releases it"),
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( kv("PgUp / PgDn (driving)", "scroll the sandbox terminal's scrollback"),
"PgUp / PgDn (driving)", kv("Up / Down · wheel", "scroll the sandbox terminal (mouse works while driving)"),
"scroll the sandbox terminal's scrollback", kv("Ctrl-R (when closed)", "reconnect to the house after a drop / AFK"),
), kv("/pw", "show this room's password (local only)"),
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(
"Ctrl+Alt+P · /theme random",
"conjure a random vestment (new palette + sigil)",
),
kv(
"/theme save [name]",
"keep the vestment you're wearing for reuse",
),
kv("Ctrl-C · Ctrl-Q", "quit hack-house"), kv("Ctrl-C · Ctrl-Q", "quit hack-house"),
Line::from(""), ],
head("ROSTER GLYPHS"), },
kv( HelpCluster {
title: "ROSTER GLYPHS",
items: vec![kv(
&format!("{} owner ⚡ sudoer", theme.sigil), &format!("{} owner ⚡ sudoer", theme.sigil),
"◆ may drive • member", "◆ 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);