Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions os/v3/puppy/console-tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions os/v3/puppy/console-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ path = "src/main.rs"
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
qrcodegen = "1.8.0"
38 changes: 31 additions & 7 deletions os/v3/puppy/console-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ facts on every draw.
```
1bit OS rc-2026-11-03-c21 strix 192.168.50.110 up 2h 13m Secure Boot enabled SoC 43C
CONNECT -------------------------------------------------------------------------------
web UI http://192.168.50.110:13305
API base http://192.168.50.110:13305/api/v1
API key press 1
ssh root@192.168.50.110
mobile Lemonade Mobile: server http://192.168.50.110:13305, key from 1
web UI http://192.168.50.110:13305 █▀▀▀▀▀█ ▄ █ ▀█▄▄
name http://1bit-os.local:13305 (mDNS) █ ███ █ ▄▄▄ █▄ █▄
api http://192.168.50.110:13305/api/v1 (API key: press 1) █ ▀▀▀ █ ▄█ ▄▄ ▀▄▄ █
ssh root@192.168.50.110 (your key in /data/ssh/authorized_keys) ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄█ ▀▀▀
app Lemonade Mobile: server http://192.168.50.110:13305 + that key (the web UI URL as a QR)
LEMONADE -------------------- GPU ---------------------------- NPU ----------------------
MODELS ---------------------- FANS / TEMPS ------------------- DATA ---------------------
MENU -----------------------------------------------------------------------------------
Expand Down Expand Up @@ -74,12 +74,36 @@ The MENU is whole and always on screen at both sizes; the log pane gets whatever
| arrows, PgUp/PgDn, Home/End | scroll the log (one keypress = one record, oldest to newest) |
| **f** | follow the newest line again |
| **r** | re-read the facts now (they refresh every 2 s while the box is coming up, every 3 s after) |
| **q** | stays on this screen: this console never drops to a bare prompt |
| **q** | stays on this screen: this console never drops to a bare prompt. Over ssh (`ssh -t root@<box> /usr/local/bin/1bit-console-tui`) it quits, as `q` does in every full-screen program - the footer says which of the two you are looking at |

Keys 2-7 hand the terminal to `1bit-console-menu --action N` - the *same* code `vm-check.sh`'s `U2 menu actions`
and `--selftest` rows exercise. Nothing that needs a typed answer is re-implemented here, so the two surfaces
cannot drift, and an action the gate proves is an action the screen has.

## Reaching it from another machine (ssh, links, QR)

The owner, 2026-11-04: *"due to being headless, can you make the links clickable, because i'm on kvm and i want use
it through ryzen"*. So the same binary runs over ssh - it makes no tty1 assumption - and three things follow from
the terminal it finds itself on:

* **Clickable URLs** (OSC 8 hyperlinks) on the CONNECT rows when `TERM` is a terminal emulator (`xterm-256color`,
`tmux`, `kitty`, ...). ratatui has no hyperlink style, so `main` writes them *after* the frame, at the cells
`ui::draw` reports in `Link`, with the drawn text as the target: the text and the link cannot disagree, and
`every_hyperlink_covers_the_url_that_is_drawn_there` asserts exactly that off the rendered frame. On the VGA
console (`TERM=linux`), JetKVM and a serial line they are **not** written - there they would be printed as
garbage - and no link is emitted for a URL that wrapping split, because half a URL opens the wrong thing.
* **A QR code** of the web UI URL, to the right of the CONNECT rows, two modules per cell (17 rows for the
appliance's 27-character URL: version 2, ECC L, `qrcodegen`, the only new dependency and one with no
transitive ones), drawn **black on white** because a scanner wants dark-on-light. The drawn code was read back
with zxing-cpp from a PNG of the 240x67 frame and decoded to `http://192.168.50.110:13305` (2026-11-04, by hand);
`a_known_url_renders_to_these_exact_rows` pins the 17 rows literally, so a change to the encoder, the ECC level or
the quiet zone fails the build instead of shipping a code nobody can read. It is only drawn where it
fits: at 80x25 there are no 33 free columns beside the block and squeezing it in would push the menu or the log
off the screen, so there the URL stays plain text - which a phone camera reads too.
* **The mDNS name** `http://1bit-os.local:13305`, next to the address, with `(mDNS)` when the box's own
`avahi-daemon` is running and `(mDNS - avahi not running)` when it is not: the row never promises a name that
will not answer. The responder itself is pi-os3's half of the request (engine #953).

## What it reads, and why from there

Every number comes from where the rest of the image reads it - no second source of truth (`src/facts.rs`):
Expand Down Expand Up @@ -125,7 +149,7 @@ dynamic one (`readelf` PT_INTERP, `ldd`).
## Tests

```sh
cargo test # 25 tests: the probe parsers + the two renders
cargo test # 43 tests: the probe parsers, the QR drawing (incl. a literal known answer), the links, the two renders
cargo run -- --snapshot 240x67 --fixture # the frame tests/snapshots/240x67.txt holds
cargo run -- --snapshot 80x25 --fixture
cargo run -- --status # the same facts as plain text (what a shell or a gate row can read)
Expand Down
35 changes: 26 additions & 9 deletions os/v3/puppy/console-tui/src/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ pub struct Facts {
pub secure_boot: SecureBoot,
pub ips: Vec<String>,
pub key: Option<String>,
/// avahi-daemon is running, i.e. the `1bit-os.local` name the CONNECT block prints can answer
pub mdns: bool,
pub lemond_pid: Option<u32>,
pub lemond_listening: bool,
/// the port is listed in `netstat -lnt` (true while the socket exists, even if nothing answers on it)
Expand Down Expand Up @@ -133,7 +135,8 @@ impl Facts {
secure_boot: secure_boot(),
ips: ips(),
key: api_key(),
lemond_pid: lemond_pid(),
mdns: false,
lemond_pid: None,
lemond_listening: lemond_listening(),
lemond_listed: false,
gpu: Gpu::default(),
Expand All @@ -145,6 +148,10 @@ impl Facts {
stats: Stats::default(),
logs: Vec::new(),
};
// one `ps` for both questions: lemond and the mDNS responder are looked up in the same snapshot
let ps = ps_snapshot();
f.lemond_pid = pid_of(&ps, "lemond");
f.mdns = pid_of(&ps, "avahi-daemon").is_some();
f.lemond_listed = Command::new("netstat")
.args(["-lnt"])
.output()
Expand Down Expand Up @@ -307,13 +314,13 @@ pub fn pid_of(ps_out: &str, name: &str) -> Option<u32> {
})
}

fn lemond_pid() -> Option<u32> {
let out = Command::new("ps")
.arg("-eo")
.arg("pid=,comm=")
/// `ps -eo pid=,comm=` once per refresh; a box without `ps` gets an empty string, not a panic.
fn ps_snapshot() -> String {
Command::new("ps")
.args(["-eo", "pid=,comm="])
.output()
.ok()?;
pid_of(&String::from_utf8_lossy(&out.stdout), "lemond")
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
}

pub fn listens_on_13305(netstat: &str) -> bool {
Expand Down Expand Up @@ -954,6 +961,8 @@ pub fn c16_board() -> Facts {
secure_boot: SecureBoot::Enabled,
ips: vec!["192.168.50.110".into()],
key: Some("93112a22cafee2ad".into()),
// the c16 payload shipped no avahi, which is why the recorded sample shows the name as not answering
mdns: false,
lemond_pid: Some(8882),
lemond_listening: true,
lemond_listed: true,
Expand Down Expand Up @@ -1126,9 +1135,17 @@ mod tests {
fs::create_dir_all(root.join("card0/device")).unwrap();
fs::create_dir_all(root.join("card1/device")).unwrap();
fs::write(root.join("card0/device/mem_info_vram_total"), "0\n").unwrap();
fs::write(root.join("card1/device/mem_info_vram_total"), "128849018880\n").unwrap();
fs::write(
root.join("card1/device/mem_info_vram_total"),
"128849018880\n",
)
.unwrap();
fs::write(root.join("card1/device/mem_info_vram_used"), "2147483648\n").unwrap();
fs::write(root.join("card1/device/mem_info_gtt_total"), "65498251264\n").unwrap();
fs::write(
root.join("card1/device/mem_info_gtt_total"),
"65498251264\n",
)
.unwrap();
fs::write(root.join("card1/device/mem_info_gtt_used"), "536870912\n").unwrap();
let mut g = Gpu::default();
gpu_mem_in(&root.to_string_lossy(), &mut g);
Expand Down
95 changes: 91 additions & 4 deletions os/v3/puppy/console-tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
//! Exit: 0 clean (1bit-console then shows the dialog menu), 1 the screen could not be set up.

mod facts;
mod qr;
mod ui;

use std::io::stdout;
Expand Down Expand Up @@ -72,7 +73,7 @@ fn main() -> ExitCode {
" 1bit-console-tui --version print the version",
"",
"keys: 1-9 actions, arrows/PgUp/PgDn scroll the log, f follow, r refresh, 8 full-screen logs,",
" q stays on this screen (this console never drops to a bare prompt).",
" q quits over ssh and stays on tty1 (the console never drops to a bare prompt).",
] {
println!("{l}");
}
Expand Down Expand Up @@ -121,6 +122,52 @@ fn suspend() {
let _ = execute!(stdout(), LeaveAlternateScreen, crossterm::cursor::Show);
}

/// Was this process started from an ssh session? `sshd` sets both of these, and neither exists on tty1, which is
/// what makes them the honest question (rather than "is there a terminal", which tty1 answers yes to).
fn ssh_session(conn: Option<&str>, tty: Option<&str>) -> bool {
conn.is_some_and(|v| !v.is_empty()) || tty.is_some_and(|v| !v.is_empty())
}

fn over_ssh() -> bool {
ssh_session(
std::env::var("SSH_CONNECTION").ok().as_deref(),
std::env::var("SSH_TTY").ok().as_deref(),
)
}

/// OSC 8 hyperlinks are a terminal-emulator feature. The VGA console (TERM=linux) has no clickable anything and
/// would print the escapes; JetKVM and a serial terminal are the same case.
fn hyperlinks_wanted(term: Option<&str>) -> bool {
matches!(term, Some(t) if !t.is_empty() && t != "linux" && t != "dumb")
}

/// Underline what was drawn and wrap it in OSC 8 with its own text as the target. Written AFTER the frame: ratatui
/// has no hyperlink style, and the two rules that make this safe are that the text is identical to the cells it
/// overwrites and that the link is re-stated on every frame (the next paint would otherwise drop it).
///
/// The style is not `\x1b[4m` alone: an SGR with no colour would inherit whatever the last drawn cell left current,
/// so the link is set to the CONNECT rows' own white+bold through the same crossterm commands ratatui uses, and
/// reset after - the cell keeps the pixel it had, plus the underline that says it can be clicked.
fn emit_links(links: &[ui::Link]) {
if links.is_empty() {
return;
}
use crossterm::cursor::MoveTo;
use crossterm::style::{Attribute, Color, Print, SetAttribute, SetForegroundColor};
let mut out = stdout();
for l in links {
let _ = execute!(
out,
MoveTo(l.x, l.y),
SetForegroundColor(Color::White),
SetAttribute(Attribute::Bold),
SetAttribute(Attribute::Underlined),
Print(format!("\x1b]8;;{u}\x1b\\{u}\x1b]8;;\x1b\\", u = l.text)),
SetAttribute(Attribute::Reset),
);
}
}

fn resume(t: &mut Terminal<CrosstermBackend<std::io::Stdout>>) {
let _ = execute!(stdout(), EnterAlternateScreen);
let _ = enable_raw_mode();
Expand Down Expand Up @@ -168,6 +215,8 @@ fn run() -> ExitCode {
});

let mut app = App::new(Facts::collect());
// asked once: sshd sets SSH_CONNECTION/SSH_TTY for the whole session, tty1 respawn sets neither
app.ssh = over_ssh();
let mut last_spin = Instant::now();
let rc = loop {
while let Ok(f) = rx.try_recv() {
Expand All @@ -184,9 +233,15 @@ fn run() -> ExitCode {
app.spin = app.spin.wrapping_add(1);
last_spin = Instant::now();
}
if term.draw(|f| ui::draw(f, &app)).is_err() {
let mut links = Vec::new();
if term.draw(|f| ui::draw(f, &app, &mut links)).is_err() {
break 1;
}
// over ssh the screen is a terminal emulator, so the CONNECT URLs can be clickable (OSC 8). On the VGA
// console (TERM=linux) the same bytes would print as garbage - or worse, as text nobody can click.
if hyperlinks_wanted(std::env::var("TERM").ok().as_deref()) {
emit_links(&links);
}

if !event::poll(Duration::from_millis(250)).unwrap_or(false) {
continue;
Expand Down Expand Up @@ -291,6 +346,10 @@ fn handle(
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
if app.full_logs {
app.full_logs = false;
} else if app.ssh {
// the owner, 2026-11-04: the TUI is used through ryzen over ssh, where q is the normal way out
// of a full-screen program. tty1 keeps the old promise: q never drops that console to a prompt.
return Some(0);
} else {
app.say("q stays: no bare prompt here (7 shell, 9 reboot)");
}
Expand Down Expand Up @@ -366,7 +425,8 @@ fn handoff(app: &mut App, term: &mut Terminal<CrosstermBackend<std::io::Stdout>>
app.modal = Some(Modal::Busy {
title: format!("action {n}"),
});
let _ = term.draw(|f| ui::draw(f, &*app));
let mut links = Vec::new();
let _ = term.draw(|f| ui::draw(f, &*app, &mut links));
suspend();
let status = std::process::Command::new(facts::MENU)
.arg("--action")
Expand All @@ -382,7 +442,34 @@ fn handoff(app: &mut App, term: &mut Terminal<CrosstermBackend<std::io::Stdout>>

#[cfg(test)]
mod tests {
use super::parse_size;
use super::{hyperlinks_wanted, parse_size, ssh_session};

#[test]
fn q_is_the_way_out_only_where_a_prompt_is_harmless() {
// sshd sets both; a tty1 respawn sets neither, and that console must never fall back to a bare prompt
assert!(ssh_session(Some("10.0.0.2 51234 10.0.0.5 22"), None));
assert!(ssh_session(None, Some("/dev/pts/3")));
assert!(!ssh_session(None, None));
assert!(!ssh_session(Some(""), Some("")));
}

#[test]
fn hyperlinks_are_for_terminal_emulators_not_the_vga_console() {
for term in [
"xterm-256color",
"screen-256color",
"tmux-256color",
"alacritty",
"kitty",
] {
assert!(hyperlinks_wanted(Some(term)), "{term}");
}
// TERM=linux is the appliance's own VGA console and a serial line: the escapes would be printed, not used
for term in ["linux", "dumb", ""] {
assert!(!hyperlinks_wanted(Some(term)), "{term}");
}
assert!(!hyperlinks_wanted(None));
}

#[test]
fn snapshot_sizes() {
Expand Down
Loading
Loading