feat(hh): help overlay (F1 / /help)

Centered modal listing every command, keybinding, and roster glyph. Opens with
F1 (desktop) or the /help command (phone-friendly, since F-keys aren't on the
Termux keyboard); any key closes it. Rendered with a Clear overlay so it floats
above the panes. Works from chat or drive mode; Ctrl-Q still quits.

9 tests pass; clean build; verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-05-30 23:20:08 -07:00
parent 51bc85e078
commit 8e6365a649
2 changed files with 91 additions and 4 deletions

View File

@ -95,6 +95,8 @@ pub struct App {
pub chat_scroll: usize, pub chat_scroll: usize,
/// Sandbox terminal scrollback: rows scrolled up from the bottom. /// Sandbox terminal scrollback: rows scrolled up from the bottom.
pub sbx_scroll: usize, pub sbx_scroll: usize,
/// Whether the help overlay is showing.
pub show_help: bool,
} }
impl App { impl App {
@ -115,6 +117,7 @@ impl App {
transfers: HashMap::new(), transfers: HashMap::new(),
chat_scroll: 0, chat_scroll: 0,
sbx_scroll: 0, sbx_scroll: 0,
show_help: false,
} }
} }
@ -405,7 +408,11 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) { if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('q')) {
break Ok(()); break Ok(());
} }
if k.code == KeyCode::F(2) { if app.show_help {
app.show_help = false; // any key dismisses the overlay
} else if k.code == KeyCode::F(1) {
app.show_help = true; // F1 from any mode
} 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() {
app.driving = !app.driving; app.driving = !app.driving;
@ -592,7 +599,9 @@ fn handle_command(
term: &Terminal<CrosstermBackend<std::io::Stdout>>, term: &Terminal<CrosstermBackend<std::io::Stdout>>,
) { ) {
let room = &session.room; let room = &session.room;
if line == "/drive" { if line == "/help" || line == "/?" {
app.show_help = true;
} else if line == "/drive" {
// Mobile-friendly alternative to F2 (no function key needed). // Mobile-friendly alternative to F2 (no function key needed).
if app.sandbox.is_none() { if app.sandbox.is_none() {
app.sys("no sandbox running — /sbx launch first"); app.sys("no sandbox running — /sbx launch first");

View File

@ -2,10 +2,10 @@
use crate::app::{App, ChatLine}; use crate::app::{App, ChatLine};
use crate::theme::Theme; use crate::theme::Theme;
use ratatui::layout::{Constraint, Layout, Position}; use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, List, ListItem, Paragraph, Wrap}; use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
@ -35,6 +35,84 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
draw_sandbox(f, area, app, theme); draw_sandbox(f, area, app, theme);
} }
draw_input(f, rows[2], app, theme); draw_input(f, rows[2], app, theme);
if app.show_help {
draw_help(f, f.area(), theme);
}
}
fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vy = (100u16.saturating_sub(percent_y)) / 2;
let vx = (100u16.saturating_sub(percent_x)) / 2;
let col = Layout::vertical([
Constraint::Percentage(vy),
Constraint::Percentage(percent_y),
Constraint::Percentage(vy),
])
.split(area)[1];
Layout::horizontal([
Constraint::Percentage(vx),
Constraint::Percentage(percent_x),
Constraint::Percentage(vx),
])
.split(col)[1]
}
fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) {
let acc = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
let key = Style::default().fg(theme.title);
let dim = Style::default().fg(theme.system);
let kv = |k: &str, v: &str| {
Line::from(vec![
Span::styled(format!(" {k:<26}"), key),
Span::styled(v.to_string(), dim),
])
};
let head = |s: &str| Line::from(Span::styled(s.to_string(), acc));
let lines = vec![
head("⛧ COMMANDS (type in the input bar)"),
kv("/sbx launch [backend]", "summon a sandbox: local | docker | multipass"),
kv("/sbx stop", "tear down the sandbox (purges the VM)"),
kv("/drive", "type into the shared shell (Esc releases)"),
kv("/grant <user>", "let a member drive the shell (owner)"),
kv("/revoke <user>", "take back drive permission (owner)"),
kv("/sudo <user>", "delegate VM superuser (real sudo) (owner)"),
kv("/unsudo <user>", "revoke VM superuser (owner)"),
kv("/send <file>", "offer a file to the room"),
kv("/sendd <dir>", "offer a directory (sent as a tar)"),
kv("/accept · /reject", "respond to an incoming file offer"),
kv("/help", "show / hide this menu"),
Line::from(""),
head("⛧ KEYS"),
kv("Enter", "send chat message"),
kv("F1 · /help", "toggle this help (any key closes it)"),
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"),
kv("Up / Down", "scroll the sandbox terminal (when not driving)"),
kv("Ctrl-Q", "quit hack-house"),
Line::from(""),
head("⛧ ROSTER GLYPHS"),
kv("⛧ owner ⚡ sudoer", "◆ may drive • member"),
Line::from(""),
Line::from(Span::styled(
" malware bless · press any key to close",
Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
)),
];
let w = centered(78, 90, area);
f.render_widget(Clear, w);
let help = Paragraph::new(lines)
.block(
Block::bordered()
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
" ⛧ hack-house — help ⛧ ",
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
)),
)
.wrap(Wrap { trim: false });
f.render_widget(help, w);
} }
fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {