ling-lang
↓ 0 downloadsLing - The Omniglot Systems Language
Keywords systems, polyglot, ai, crypto, game
lingfu install ling-langCode / Files (995)
crates/ling-kernel/src/drivers/terminal.rs
//! A desktop terminal window: a colored, themed, zoomable shell. The grid
//! resolution is derived from the window size divided by the zoomed glyph
//! size, so Ctrl +/- literally changes how many characters fit -- "text
//! resolution by text-size zoom", as asked.
//!
//! The shell here is kernel-side and independent of `lsh` (which is a
//! separate `.ling` program compiled into the text kernel, not this
//! graphics one). It supports a useful built-in command set wired
//! straight to kernel services -- lingfs (`ls`/`cat`), the network
//! (`curl`, `dns`), the clock (`date`), theming (`theme`), clipboard
//! (`copy`/`paste`) -- rather than pretending to run arbitrary programs
//! (there's no process model here; disclosed). Output lines carry a color
//! class so errors read red, ok green, and command echoes accent.
use crate::arch::rtc;
use crate::drivers::{clipboard, font8x8, framebuffer, lingfu, llm, media, mixer, netstack, theme};
use crate::fs::{lingfs, users};
const COLS: usize = 128; // max stored chars per line
const LINES: usize = 240; // scrollback ring
const INPUT_MAX: usize = 200;
#[derive(Clone, Copy, PartialEq)]
enum Cls {
Normal,
Accent,
Ok,
Err,
Dim,
}
#[derive(Clone, Copy)]
struct Row {
text: [u8; COLS],
len: usize,
cls: Cls,
}
const EMPTY_ROW: Row = Row { text: [0; COLS], len: 0, cls: Cls::Normal };
static mut ROWS: [Row; LINES] = [EMPTY_ROW; LINES];
static mut HEAD: usize = 0; // next write index (ring)
static mut COUNT: usize = 0; // rows written (<= LINES)
static mut INPUT: [u8; INPUT_MAX] = [0; INPUT_MAX];
static mut INPUT_LEN: usize = 0;
static mut ZOOM: u32 = 1;
static mut SCROLL: usize = 0; // rows scrolled up from the bottom
static mut STARTED: bool = false;
// Command history (up/down recall), like a real shell.
const HIST_N: usize = 32;
static mut HIST: [[u8; INPUT_MAX]; HIST_N] = [[0; INPUT_MAX]; HIST_N];
static mut HIST_LEN: [usize; HIST_N] = [0; HIST_N];
static mut HIST_HEAD: usize = 0; // next write slot (ring)
static mut HIST_COUNT: usize = 0; // entries stored (<= HIST_N)
static mut HIST_NAV: usize = 0; // steps back currently recalled (0 = fresh line)
// Current working directory: a single lingfs dir name (empty = root). lingfs
// is one level deep, so this is at most one component.
static mut CWD: [u8; 64] = [0; 64];
static mut CWD_LEN: usize = 0;
// sudo/su password entry: 0 = normal input, 1 = collecting a sudo password,
// 2 = collecting an su password. PENDING holds the sudo command line (mode 1)
// or the target username (mode 2).
static mut PW_MODE: u8 = 0;
static mut PW_INPUT: [u8; INPUT_MAX] = [0; INPUT_MAX];
static mut PW_LEN: usize = 0;
static mut PENDING: [u8; INPUT_MAX] = [0; INPUT_MAX];
static mut PENDING_LEN: usize = 0;
fn rows() -> &'static mut [Row; LINES] {
unsafe { &mut *&raw mut ROWS }
}
fn push(cls: Cls, s: &[u8]) {
unsafe {
let r = &mut rows()[HEAD];
let n = s.len().min(COLS);
r.text[..n].copy_from_slice(&s[..n]);
r.len = n;
r.cls = cls;
HEAD = (HEAD + 1) % LINES;
if COUNT < LINES {
COUNT += 1;
}
SCROLL = 0; // jump to bottom on new output
}
}
/// Append possibly-multi-line bytes, splitting on '\n' and wrapping long
/// lines at COLS -- the shape terminal output actually arrives in.
fn push_wrapped(cls: Cls, bytes: &[u8]) {
let mut line_start = 0;
for i in 0..=bytes.len() {
let at_nl = i < bytes.len() && bytes[i] == b'\n';
let at_end = i == bytes.len();
if at_nl || at_end || i - line_start >= COLS {
push(cls, &bytes[line_start..i]);
line_start = if at_nl { i + 1 } else { i };
if at_end {
break;
}
}
}
}
fn banner() {
push(Cls::Accent, b"LingOS terminal -- type 'help'");
push(Cls::Dim, b"a graphics-mode shell (separate from the text-mode lsh)");
}
fn prompt_echo() {
unsafe {
let mut line = [0u8; INPUT_MAX + 8];
line[0] = b'>';
line[1] = b' ';
let n = INPUT_LEN.min(INPUT_MAX);
line[2..2 + n].copy_from_slice(&(&*&raw const INPUT)[..n]);
push(Cls::Accent, &line[..2 + n]);
}
}
fn input_str() -> &'static str {
unsafe { core::str::from_utf8(&(&*&raw const INPUT)[..INPUT_LEN]).unwrap_or("") }
}
/// Run `line` in this terminal exactly as if the user typed and submitted it:
/// echo the prompt, record it in history, dispatch. The WM uses this to launch
/// an installed package (`ling run pkg-<name>/main.ling`) into a Terminal
/// window from the start menu, reusing all the terminal's rendering + the
/// in-kernel Ling interpreter rather than duplicating an app runner.
pub fn run_command(line: &str) {
unsafe {
if !STARTED {
STARTED = true;
banner();
}
let mut echo = [0u8; INPUT_MAX + 8];
echo[0] = b'>';
echo[1] = b' ';
let n = line.len().min(INPUT_MAX);
echo[2..2 + n].copy_from_slice(&line.as_bytes()[..n]);
push(Cls::Accent, &echo[..2 + n]);
history_push(&line.as_bytes()[..n]);
run(&line[..n]);
}
}
fn cwd_str() -> &'static str {
unsafe { core::str::from_utf8(&(&*&raw const CWD)[..CWD_LEN]).unwrap_or("") }
}
/// True if `name` is a directory at lingfs root (for `cd`).
fn dir_exists(name: &str) -> bool {
let mut idx = 0;
let mut nm = [0u8; 64];
while idx < 256 {
match lingfs::list_entry("", idx, &mut nm) {
Some((len, is_dir)) => {
if is_dir && &nm[..len] == name.as_bytes() {
return true;
}
idx += 1;
},
None => break,
}
}
false
}
/// Resolve a possibly-relative path against the CWD into `out`, returning the
/// slice. An absolute-looking path (with a '/') or an empty CWD passes
/// through unchanged; otherwise "cwd/name".
fn resolve_path<'a>(arg: &str, out: &'a mut [u8; 128]) -> &'a str {
let cwd = cwd_str();
if cwd.is_empty() || arg.contains('/') {
let n = arg.len().min(out.len());
out[..n].copy_from_slice(&arg.as_bytes()[..n]);
return core::str::from_utf8(&out[..n]).unwrap_or("");
}
let mut n = 0;
let cb = cwd.as_bytes();
let cl = cb.len().min(out.len());
out[..cl].copy_from_slice(&cb[..cl]);
n += cl;
if n < out.len() {
out[n] = b'/';
n += 1;
}
let ab = arg.as_bytes();
let al = ab.len().min(out.len() - n);
out[n..n + al].copy_from_slice(&ab[..al]);
n += al;
core::str::from_utf8(&out[..n]).unwrap_or("")
}
/// Record a command in history (skips empties and consecutive duplicates).
fn history_push(cmd: &[u8]) {
unsafe {
HIST_NAV = 0;
if cmd.is_empty() {
return;
}
if HIST_COUNT > 0 {
let last = (HIST_HEAD + HIST_N - 1) % HIST_N;
if HIST_LEN[last] == cmd.len() && HIST[last][..cmd.len()] == *cmd {
return;
}
}
let n = cmd.len().min(INPUT_MAX);
HIST[HIST_HEAD][..n].copy_from_slice(&cmd[..n]);
HIST_LEN[HIST_HEAD] = n;
HIST_HEAD = (HIST_HEAD + 1) % HIST_N;
if HIST_COUNT < HIST_N {
HIST_COUNT += 1;
}
}
}
/// Recall a history entry into the input line. `delta` +1 = further back,
/// -1 = toward the fresh line.
fn history_recall(delta: i32) {
unsafe {
if HIST_COUNT == 0 {
return;
}
let new_nav = (HIST_NAV as i32 + delta).clamp(0, HIST_COUNT as i32) as usize;
HIST_NAV = new_nav;
if new_nav == 0 {
INPUT_LEN = 0;
return;
}
let idx = (HIST_HEAD + HIST_N - new_nav) % HIST_N;
let len = HIST_LEN[idx];
INPUT[..len].copy_from_slice(&HIST[idx][..len]);
INPUT_LEN = len;
}
}
fn split_cmd(s: &str) -> (&str, &str) {
match s.find(' ') {
Some(i) => (&s[..i], s[i + 1..].trim_start()),
None => (s, ""),
}
}
/// show whatever the model task has produced since the last look
fn pump_llm(cols: usize) {
if !llm::has_output() {
return;
}
llm::drain_lines(cols.clamp(20, COLS - 1), &mut |kind, s| {
let cls = match kind {
0 => Cls::Normal,
1 => Cls::Dim,
_ => Cls::Err,
};
push(cls, s.as_bytes());
});
}
fn run(line: &str) {
pump_llm(COLS - 1);
let (cmd, arg) = split_cmd(line.trim());
match cmd {
"" => {},
"ls" if crate::fs::media::is_media_path(arg) => {
let items = crate::vfs::list(arg);
if items.is_empty() {
push(Cls::Dim, b"(empty)");
}
for (name, is_dir, size) in items {
let mut line = alloc::string::String::from(name.as_str());
if is_dir {
line.push('/');
} else {
line.push_str(&alloc::format!(" {size}"));
}
push(Cls::Normal, line.as_bytes());
}
},
"cat" if crate::fs::media::is_media_path(arg) => match crate::vfs::read(arg) {
Some(bytes) => push_wrapped(Cls::Normal, &bytes[..bytes.len().min(8192)]),
None => push(Cls::Err, b"cat: no such file"),
},
"mkdir" if crate::fs::media::is_media_path(arg) => {
if crate::vfs::mkdir(arg) {
push(Cls::Ok, b"created");
} else {
push(Cls::Err, b"mkdir: failed");
}
},
"help" => {
push(Cls::Normal, b"commands:");
push(Cls::Normal, b" help clear echo <t> ls [dir] cat <file>");
push(Cls::Normal, b" date theme dark|light copy <t> paste");
push(Cls::Normal, b" dns <host> curl <url> bring <url>");
push(Cls::Normal, b" play <file.wav> stop");
push(Cls::Normal, b" lingfu sync|search <q>|install <name>");
push(Cls::Normal, b" ling run <file.ling>|eval <src> run demo|donut|<app.elf>");
push(Cls::Normal, b" cd <dir> sudo <cmd> su [user] ling-life");
push(Cls::Normal, b" ssh [user@]host services [enable|disable ssh] cryptotest");
push(Cls::Normal, b" https <url> imgview demo|demosvg|<url>");
push(Cls::Normal, b" ask <question> llm [status|load|stop] (Qwen3, on this machine)");
push(Cls::Dim, b" up/down: command history");
},
"clear" => unsafe {
COUNT = 0;
HEAD = 0;
SCROLL = 0;
},
"echo" => push(Cls::Normal, arg.as_bytes()),
"ask" => {
if arg.is_empty() {
push(Cls::Err, b"usage: ask <question>");
} else if !llm::start() {
push(Cls::Err, b"ask: no free task slot for the model");
} else if !llm::ask(arg) {
push(Cls::Dim, b"llm is still busy with the last question (llm stop)");
} else if llm::phase() == llm::Phase::Idle || llm::phase() == llm::Phase::Failed {
push(Cls::Dim, b"asking: the first question loads the model from the model disk...");
} else {
push(Cls::Dim, b"asking...");
}
},
"llm" => match arg {
"" | "status" => {
let s = llm::status();
push(Cls::Normal, s.as_bytes());
},
"load" => {
if llm::start() {
llm::request_load();
push(Cls::Dim, b"loading the model from the model disk...");
} else {
push(Cls::Err, b"llm: no free task slot");
}
},
"stop" => {
llm::cancel();
push(Cls::Dim, b"stopping");
},
_ => push(Cls::Err, b"usage: llm [status|load|stop]"),
},
"play" => {
if arg.is_empty() {
push(Cls::Err, b"usage: play <file.wav>");
} else if media::open(arg) {
mixer::pcm_play();
push(Cls::Ok, b"playing (media)");
} else {
push(Cls::Err, media::status().as_bytes());
}
},
"stop" => {
mixer::pcm_stop();
push(Cls::Dim, b"stopped");
},
"date" => {
let dt = rtc::read();
let mut b = [0u8; 32];
let mut n = 0;
n += w4(&mut b[n..], dt.year as u32);
b[n] = b'-'; n += 1;
n += w2(&mut b[n..], dt.month as u32);
b[n] = b'-'; n += 1;
n += w2(&mut b[n..], dt.day as u32);
b[n] = b' '; n += 1;
n += w2(&mut b[n..], dt.hour as u32);
b[n] = b':'; n += 1;
n += w2(&mut b[n..], dt.minute as u32);
b[n] = b':'; n += 1;
n += w2(&mut b[n..], dt.second as u32);
push(Cls::Normal, &b[..n]);
push(Cls::Dim, b"(CMOS RTC, UTC)");
},
"theme" => {
if arg == "light" {
theme::set(1);
} else if arg == "dark" {
theme::set(0);
} else {
push(Cls::Err, b"usage: theme dark|light");
}
},
"copy" => {
clipboard::set(arg.as_bytes());
push(Cls::Ok, b"copied to clipboard");
},
"paste" => push(Cls::Normal, clipboard::get()),
"ls" => {
let dir = if arg.is_empty() { cwd_str() } else { arg };
let mut idx = 0;
let mut nm = [0u8; 64];
let mut any = false;
while idx < 128 {
match lingfs::list_entry(dir, idx, &mut nm) {
Some((len, is_dir)) => {
let mut line = [0u8; 70];
let mut n = 0;
line[..len].copy_from_slice(&nm[..len]);
n += len;
if is_dir {
line[n] = b'/';
n += 1;
}
push(if is_dir { Cls::Accent } else { Cls::Normal }, &line[..n]);
any = true;
idx += 1;
},
None => break,
}
}
if !any {
push(Cls::Dim, b"(empty)");
}
},
"cat" => {
if arg.is_empty() {
push(Cls::Err, b"usage: cat <file>");
} else {
static mut CATBUF: [u8; 8192] = [0; 8192];
let cb = unsafe { &mut *&raw mut CATBUF };
let mut pbuf = [0u8; 128];
let path = resolve_path(arg, &mut pbuf);
match lingfs::read_file_all(path, cb) {
Ok(Some(len)) => push_wrapped(Cls::Normal, &cb[..len]),
_ => push(Cls::Err, b"cat: no such file"),
}
}
},
"cp" | "mv" => {
let mut it = arg.split_whitespace();
let (Some(a), Some(b)) = (it.next(), it.next()) else {
push(Cls::Err, if cmd == "cp" { b"usage: cp <src> <dst>" } else { b"usage: mv <src> <dst>" });
return;
};
let (mut pa, mut pb) = ([0u8; 128], [0u8; 128]);
let src = alloc::string::String::from(resolve_path(a, &mut pa));
let dst = crate::vfs::target_for(&src, resolve_path(b, &mut pb));
let ok = if cmd == "cp" { crate::vfs::copy(&src, &dst) } else { crate::vfs::rename(&src, &dst) };
if ok {
push(Cls::Ok, dst.as_bytes());
} else {
push(Cls::Err, b"failed (source missing, destination not writable, or folder not empty)");
}
},
"rm" => {
if arg.is_empty() {
push(Cls::Err, b"usage: rm <path>");
} else {
let mut pb = [0u8; 128];
let p = resolve_path(arg, &mut pb);
if crate::vfs::delete(p) {
push(Cls::Ok, b"deleted");
} else {
push(Cls::Err, b"rm: not deleted (missing, a non-empty folder, or read-only storage)");
}
}
},
"dns" => {
if arg.is_empty() {
push(Cls::Err, b"usage: dns <host>");
} else {
push(Cls::Dim, b"resolving...");
match netstack::dns_resolve(arg) {
Some(ip) => {
let mut b = [0u8; 32];
let mut n = 0;
for (k, oct) in ip.iter().enumerate() {
if k > 0 {
b[n] = b'.';
n += 1;
}
n += w_any(&mut b[n..], *oct as u32);
}
push(Cls::Ok, &b[..n]);
},
None => push(Cls::Err, b"dns: could not resolve"),
}
}
},
"curl" => {
if arg.is_empty() {
push(Cls::Err, b"usage: curl <url>");
} else {
fetch_and_dump(arg, false);
}
},
"bring" => {
if arg.is_empty() {
push(Cls::Err, b"usage: bring <url>");
} else {
fetch_and_dump(arg, true);
}
},
"lingfu" => run_lingfu(arg),
"ling" => run_ling(arg),
"run" => run_native(arg),
"ssh" => run_ssh(arg),
"services" => run_services(arg),
"https" => run_https(arg),
"imgview" => run_imgview(arg),
"cd" => {
if arg.is_empty() || arg == "/" || arg == "~" || arg == ".." {
unsafe { CWD_LEN = 0 };
} else if dir_exists(arg) {
let b = arg.as_bytes();
let n = b.len().min(64);
unsafe {
CWD[..n].copy_from_slice(&b[..n]);
CWD_LEN = n;
}
} else {
push(Cls::Err, b"cd: no such directory");
}
},
"pwd" => {
let c = cwd_str();
if c.is_empty() {
push(Cls::Normal, b"/");
} else {
let mut b = [0u8; 66];
b[0] = b'/';
let n = c.len().min(64);
b[1..1 + n].copy_from_slice(&c.as_bytes()[..n]);
push(Cls::Normal, &b[..1 + n]);
}
},
"whoami" => push(Cls::Normal, lingfs::current_user().as_bytes()),
"sudo" => {
if arg.is_empty() {
push(Cls::Err, b"usage: sudo <command>");
} else {
let b = arg.as_bytes();
let n = b.len().min(INPUT_MAX);
unsafe {
PENDING[..n].copy_from_slice(&b[..n]);
PENDING_LEN = n;
PW_MODE = 1;
PW_LEN = 0;
}
let mut m = [0u8; 64];
let p = b"[sudo] password for ";
m[..p.len()].copy_from_slice(p);
let u = lingfs::current_user();
let un = u.len().min(m.len() - p.len() - 1);
m[p.len()..p.len() + un].copy_from_slice(&u.as_bytes()[..un]);
m[p.len() + un] = b':';
push(Cls::Dim, &m[..p.len() + un + 1]);
}
},
"su" => {
let target = if arg.is_empty() { "root" } else { arg };
let b = target.as_bytes();
let n = b.len().min(INPUT_MAX);
unsafe {
PENDING[..n].copy_from_slice(&b[..n]);
PENDING_LEN = n;
PW_MODE = 2;
PW_LEN = 0;
}
push(Cls::Dim, b"password:");
},
"ling-life" => {
// life::run() is the fullscreen text-mode idle screensaver; it
// takes over the console and can't render inside this framebuffer
// window, so don't launch it from here (it would freeze the WM).
push(Cls::Normal, b"ling-life: Conway's Life screensaver.");
push(Cls::Dim, b"It runs fullscreen on idle; a windowed Life app is on the roadmap.");
},
"cryptotest" => {
// Prove the TLS 1.3 crypto suite computes correct answers in-kernel
// (known-answer test vectors), not just that it compiled.
let t = crate::crypto::selftest();
let mark = |ok: bool| if ok { b"[ok] ".as_slice() } else { b"[FAIL]".as_slice() };
let mut row = |ok: bool, name: &[u8]| {
let mut b = [0u8; 48];
let m = mark(ok);
b[..m.len()].copy_from_slice(m);
b[m.len()] = b' ';
let n = name.len().min(b.len() - m.len() - 1);
b[m.len() + 1..m.len() + 1 + n].copy_from_slice(&name[..n]);
push(if ok { Cls::Ok } else { Cls::Err }, &b[..m.len() + 1 + n]);
};
push(Cls::Accent, b"TLS crypto self-test (known-answer vectors):");
row(t.rdrand, b"RDRAND entropy");
row(t.sha256, b"SHA-256 (FIPS 180-4)");
row(t.sha512, b"SHA-512 (FIPS 180-4)");
row(t.hmac, b"HMAC-SHA256 (RFC 4231)");
row(t.hkdf, b"HKDF-SHA256 (RFC 5869)");
row(t.chachapoly, b"ChaCha20-Poly1305 (RFC 8439)");
row(t.x25519, b"X25519 ECDHE (RFC 7748)");
row(t.ed25519, b"Ed25519 sign+vfy (RFC 8032)");
if t.all_ok() {
push(Cls::Ok, b"all primitives pass -- ready for TLS + SSH");
} else {
push(Cls::Err, b"some primitives failed");
}
},
"alloctest" => {
// Exercise the new kernel global allocator: a growing Vec (which
// reallocs), a String, then drop (which frees). Prints a known
// checksum -- sum of i*i for i in 0..1000 == 332833500 -- so a
// correct allocator is verifiable at a glance.
let mut v: alloc::vec::Vec<u32> = alloc::vec::Vec::new();
let mut i: u32 = 0;
while i < 1000 {
v.push(i * i);
i += 1;
}
let mut sum: u32 = 0;
for x in v.iter() {
sum = sum.wrapping_add(*x);
}
let mut s = alloc::string::String::new();
s.push_str("alloc ok: sum=");
let mut nb = [0u8; 16];
let n = w_any(&mut nb, sum);
if let Ok(ds) = core::str::from_utf8(&nb[..n]) {
s.push_str(ds);
}
s.push_str(" len=");
let mut lb = [0u8; 16];
let ln = w_any(&mut lb, v.len() as u32);
if let Ok(ls) = core::str::from_utf8(&lb[..ln]) {
s.push_str(ls);
}
push(Cls::Ok, s.as_bytes());
// v and s drop here, exercising free().
},
_ => {
let mut b = [0u8; 96];
let p = b"unknown command: ";
b[..p.len()].copy_from_slice(p);
let n = (cmd.len()).min(b.len() - p.len());
b[p.len()..p.len() + n].copy_from_slice(&cmd.as_bytes()[..n]);
push(Cls::Err, &b[..p.len() + n]);
},
}
}
/// The desktop-terminal front end for the kernel `lingfu` package client.
/// Handles the *package-management* verbs that work without a compiler
/// (sync/search/install/update); the project/toolchain verbs (new, build,
/// run, test, ...) need the `ling` compiler, which doesn't run inside LingOS
/// yet -- so we say so plainly rather than pretend. lingfu writes through a
/// capture sink here so its output lands in this window, not the text console.
fn run_lingfu(arg: &str) {
let (sub, rest) = split_cmd(arg);
match sub {
"" | "help" => {
push(Cls::Accent, b"lingfu -- LingOS package client");
push(Cls::Normal, b" lingfu sync refresh the catalog from the repo");
push(Cls::Normal, b" lingfu search <query> find packages");
push(Cls::Normal, b" lingfu install <name> download + install a package");
push(Cls::Normal, b" lingfu update re-sync the catalog");
push(Cls::Dim, b" new/build/run/test need the ling compiler (host-only for now)");
},
"new" | "init" | "build" | "run" | "test" | "check" | "clean" | "doc" | "fmt"
| "bench" | "tree" | "publish" | "add" | "manifest" | "wizard" => {
push(Cls::Err, b"lingfu: that verb needs the ling compiler/toolchain,");
push(Cls::Err, b"which doesn't run inside LingOS yet (no userspace/loader).");
push(Cls::Dim, b"Package verbs that DO work here: sync, search, install, update.");
},
"sync" | "update" => {
push(Cls::Dim, b"contacting repo...");
lingfu::begin_capture();
lingfu::sync();
dump_capture();
},
"search" | "list" => {
lingfu::begin_capture();
if !lingfu::synced() {
lingfu::sync();
}
lingfu::list(rest);
dump_capture();
},
"install" => {
if rest.is_empty() {
push(Cls::Err, b"usage: lingfu install <name>");
} else {
push(Cls::Dim, b"installing...");
lingfu::begin_capture();
lingfu::install(rest);
dump_capture();
}
},
other => {
let mut b = [0u8; 64];
let p = b"lingfu: unknown subcommand: ";
b[..p.len()].copy_from_slice(p);
let n = other.len().min(b.len() - p.len());
b[p.len()..p.len() + n].copy_from_slice(&other.as_bytes()[..n]);
push(Cls::Err, &b[..p.len() + n]);
},
}
}
/// `ling run <file.ling> [args]` / `ling eval <source>` -- run a Ling program
/// with the in-kernel compiler + interpreter (crate::lingc: the real lexer,
/// parser and MIR lowering, interpreted over the same runtime bundled apps
/// use). Output and any error are printed into this window.
fn run_ling(arg: &str) {
let (sub, rest) = split_cmd(arg);
match sub {
"" | "help" | "--help" | "-h" => {
push(Cls::Accent, b"ling -- run Ling programs (in-kernel compiler + interpreter)");
push(Cls::Normal, b" ling run <file.ling> [args] run a program (a folder runs its main.ling)");
push(Cls::Normal, b" ling eval <source> run a one-line program");
push(Cls::Normal, b" ling demo a built-in test program");
push(Cls::Dim, b" the whole language; builtins: core, strings, lists, st_*, files, px_* graphics, daw_*");
},
"eval" => {
if rest.is_empty() {
push(Cls::Err, b"usage: ling eval <source>");
} else {
run_ling_source(rest);
}
},
"demo" => run_ling_source(crate::ling::DEMO),
"run" => {
let mut words = rest.split(' ').filter(|w| !w.is_empty());
let Some(file) = words.next() else {
push(Cls::Err, b"usage: ling run <file.ling> [args]");
return;
};
let args: alloc::vec::Vec<&str> = words.collect();
let mut pbuf = [0u8; 128];
let path = resolve_path(file, &mut pbuf);
// Relative to the current folder first, then as typed from the
// root (`ling run pkg-<name>` works from anywhere).
let (out, err) = crate::ling::run_file(&[path, file], &args);
show_ling_result(out, err);
},
_ => push(Cls::Err, b"ling: unknown subcommand (try: ling run <file> | ling eval <src>)"),
}
}
/// `run demo` / `run <path.elf>` -- load a native AOT-compiled Ling app into
/// a ring-3 process and run it to completion, showing its output here. This
/// is the real native-app path (Cranelift AOT -> ling-user -> ELF loader ->
/// ring 3 -> syscalls), distinct from `ling run` (the in-kernel interpreter).
fn run_native(arg: &str) {
// The donut is a graphics app: it maps the framebuffer and draws straight
// to the screen (no text output to capture), then returns when it exits.
if arg == "donut" {
push(Cls::Dim, b"spinning up the donut (press any key to exit)...");
let result = crate::run_app_bytes(crate::DONUT_APP_ELF);
report_exit(result);
return;
}
push(Cls::Dim, b"launching native app (ring 3)...");
crate::console_capture_begin();
let result = if arg.is_empty() || arg == "demo" {
crate::run_app_bytes(crate::HELLO_APP_ELF)
} else {
let mut pbuf = [0u8; 128];
let path = resolve_path(arg, &mut pbuf);
crate::run_app_file(path)
};
let cap = crate::console_capture_end();
for line in cap.split(|&b| b == b'\n') {
if !line.is_empty() {
push_wrapped(Cls::Normal, line);
}
}
report_exit(result);
}
fn report_exit(result: Result<i32, &'static str>) {
match result {
Ok(code) => {
let mut b = [0u8; 32];
let p = b"[exited, code ";
b[..p.len()].copy_from_slice(p);
let mut n = p.len();
n += w_any(&mut b[n..], code as u32);
b[n] = b']';
n += 1;
push(Cls::Ok, &b[..n]);
},
Err(e) => {
let mut b = [0u8; 96];
let p = b"run: ";
b[..p.len()].copy_from_slice(p);
let k = e.len().min(b.len() - p.len());
b[p.len()..p.len() + k].copy_from_slice(&e.as_bytes()[..k]);
push(Cls::Err, &b[..p.len() + k]);
},
}
}
/// `ssh [user@]host[:port]` -- connect over TCP and do the SSH version-banner
/// exchange (RFC 4253's first step), showing the server's banner. The full
/// SSH transport (KEX/auth/shell) is the large protocol work that builds on
/// crypto.rs; this proves the client reaches and speaks to a real sshd.
fn run_ssh(arg: &str) {
if arg.is_empty() {
push(Cls::Err, b"usage: ssh [user@]host[:port]");
return;
}
let hostport = match arg.rfind('@') {
Some(i) => &arg[i + 1..],
None => arg,
};
let (host, port) = match hostport.rfind(':') {
Some(i) => (&hostport[..i], hostport[i + 1..].parse::<u16>().unwrap_or(22)),
None => (hostport, 22u16),
};
push(Cls::Dim, b"resolving...");
let Some(ip) = netstack::dns_resolve(host) else {
push(Cls::Err, b"ssh: could not resolve host");
return;
};
push(Cls::Dim, b"connecting...");
if !netstack::tcp_connect(ip, port) {
push(Cls::Err, b"ssh: connection refused / no route");
return;
}
netstack::tcp_write(b"SSH-2.0-LingOS_0.1\r\n");
let mut buf = [0u8; 512];
let n = netstack::tcp_read_some(&mut buf, 3_000_000);
if n == 0 {
push(Cls::Err, b"ssh: connected, but no banner received");
return;
}
let mut end = 0;
while end < n && buf[end] != b'\r' && buf[end] != b'\n' {
end += 1;
}
push(Cls::Ok, &buf[..end]);
push(Cls::Dim, b"ssh: TCP + SSH version banner exchanged with the server.");
push(Cls::Dim, b" KEX/auth/shell (the SSH transport) build on the crypto suite -- WIP.");
}
const PNG_SIG: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
/// `imgview [demo|<url>]` -- fetch (http/https) and decode an image, then show
/// it fullscreen (press a key to return). PNG (8-bit) and subset SVG.
fn run_imgview(arg: &str) {
static mut IMGBUF: [u8; 512 * 1024] = [0; 512 * 1024];
let ib = unsafe { &mut *&raw mut IMGBUF };
let (len, off): (usize, usize) = if arg.is_empty() || arg == "demo" {
static TEST_PNG: &[u8] = include_bytes!("../../testbins/test.png");
let n = TEST_PNG.len().min(ib.len());
ib[..n].copy_from_slice(&TEST_PNG[..n]);
(n, 0)
} else if arg == "demosvg" {
static TEST_SVG: &[u8] = include_bytes!("../../testbins/test.svg");
let n = TEST_SVG.len().min(ib.len());
ib[..n].copy_from_slice(&TEST_SVG[..n]);
(n, 0)
} else {
let Some((host, port, path, tls)) = netstack::parse_url(arg) else {
push(Cls::Err, b"imgview: bad URL");
return;
};
push(Cls::Dim, b"imgview: fetching...");
if tls {
let hport = if port == 0 { 443 } else { port };
let mut noop = |_: &[u8]| {};
match crate::tls::https_get(host, hport, path, ib, &mut noop) {
Ok(n) if n > 0 => (n, crate::tls::http_body_offset(&ib[..n])),
_ => {
push(Cls::Err, b"imgview: https fetch failed");
return;
},
}
} else {
let Some(ip) = netstack::dns_resolve(host) else {
push(Cls::Err, b"imgview: DNS failed");
return;
};
match netstack::http_get(ip, port, path, host, ib) {
Some(n) => (n, 0),
None => {
push(Cls::Err, b"imgview: fetch failed");
return;
},
}
}
};
let bytes = &ib[off..len];
let img = if bytes.len() >= 8 && bytes[..8] == PNG_SIG {
crate::drivers::image::decode_png(bytes)
} else if crate::drivers::image::looks_svg(bytes) {
crate::drivers::image::decode_svg(bytes)
} else {
None
};
match img {
Some(im) => {
push(Cls::Ok, b"imgview: decoded -- showing (press a key)");
crate::drivers::image::show_fullscreen(&im);
crate::arch::timer::poll_until(20_000_000, || crate::drivers::keyboard::poll_char() != 0);
},
None => push(Cls::Err, b"imgview: could not decode (PNG 8-bit or subset SVG)"),
}
}
/// `https <host[:port][/path]>` -- a real TLS 1.3 handshake (X25519 +
/// ChaCha20-Poly1305) and an encrypted GET, showing the decrypted response.
/// Certificate validation is NOT performed yet (disclosed) -- this proves the
/// handshake + record crypto, not authenticity.
fn run_https(arg: &str) {
if arg.is_empty() {
push(Cls::Err, b"usage: https <host[:port][/path]>");
return;
}
let a = arg.strip_prefix("https://").unwrap_or(arg);
let (hostpart, path) = match a.find('/') {
Some(i) => (&a[..i], &a[i..]),
None => (a, "/"),
};
let (host, port) = match hostpart.rfind(':') {
Some(i) => (&hostpart[..i], hostpart[i + 1..].parse::<u16>().unwrap_or(443)),
None => (hostpart, 443u16),
};
push(Cls::Dim, b"tls: cert validation is NOT done yet -- channel is encrypted, not authenticated");
static mut HTTPSBUF: [u8; 32 * 1024] = [0; 32 * 1024];
let buf = unsafe { &mut *&raw mut HTTPSBUF };
let mut logline = |m: &[u8]| {
let m = if m.last() == Some(&b'\n') { &m[..m.len() - 1] } else { m };
push(Cls::Dim, m);
};
match crate::tls::https_get(host, port, path, buf, &mut logline) {
Ok(0) => push(Cls::Err, b"https: handshake ok but no data decrypted"),
Ok(len) => {
push(Cls::Ok, b"https: response (decrypted):");
push_wrapped(Cls::Normal, &buf[..len.min(2048)]);
},
Err(e) => {
let mut b = [0u8; 96];
let p = b"https: ";
b[..p.len()].copy_from_slice(p);
let k = e.len().min(b.len() - p.len());
b[p.len()..p.len() + k].copy_from_slice(&e.as_bytes()[..k]);
push(Cls::Err, &b[..p.len() + k]);
},
}
}
/// `services [list|enable ssh|disable ssh]` -- configure which services start
/// at boot (persisted to lingfs /services, honored by services::boot_configure).
fn run_services(arg: &str) {
let (sub, rest) = split_cmd(arg);
match sub {
"" | "list" | "status" => {
if crate::services::ssh_enabled() {
push(Cls::Ok, b" ssh enabled (starts at boot)");
} else {
push(Cls::Dim, b" ssh disabled");
}
push(Cls::Dim, b" services enable ssh | services disable ssh");
},
"enable" => {
if rest == "ssh" {
crate::services::set_ssh(true);
push(Cls::Ok, b"ssh enabled -- starts at next boot");
} else {
push(Cls::Err, b"services: unknown service (only 'ssh')");
}
},
"disable" => {
if rest == "ssh" {
crate::services::set_ssh(false);
push(Cls::Ok, b"ssh disabled");
} else {
push(Cls::Err, b"services: unknown service (only 'ssh')");
}
},
_ => push(Cls::Err, b"usage: services [list|enable ssh|disable ssh]"),
}
}
fn run_ling_source(src: &str) {
let (out, err) = crate::ling::run_source(src);
show_ling_result(out, err);
}
fn show_ling_result(out: alloc::string::String, err: Option<alloc::string::String>) {
if !out.is_empty() {
push_wrapped(Cls::Normal, out.as_bytes());
}
if let Some(e) = err {
let mut b = [0u8; 128];
let p = b"ling error: ";
b[..p.len()].copy_from_slice(p);
let n = e.len().min(b.len() - p.len());
b[p.len()..p.len() + n].copy_from_slice(&e.as_bytes()[..n]);
push(Cls::Err, &b[..p.len() + n]);
}
}
/// Push everything lingfu captured this call into the terminal, one line per
/// output line (blank lines dropped).
fn dump_capture() {
let out = lingfu::end_capture();
for line in out.split(|&b| b == b'\n') {
if !line.is_empty() {
push_wrapped(Cls::Normal, line);
}
}
}
fn fetch_and_dump(url: &str, render: bool) {
let Some((host, port, path, tls)) = netstack::parse_url(url) else {
push(Cls::Err, b"bad URL");
return;
};
push(Cls::Dim, b"fetching...");
static mut WEBBUF: [u8; 32 * 1024] = [0; 32 * 1024];
let wb = unsafe { &mut *&raw mut WEBBUF };
let (len, off) = if tls {
// HTTPS via the in-kernel TLS 1.3 client (cert validation not done yet).
let hport = if port == 0 { 443 } else { port };
let mut noop = |_: &[u8]| {};
match crate::tls::https_get(host, hport, path, wb, &mut noop) {
Ok(n) if n > 0 => (n, crate::tls::http_body_offset(&wb[..n])),
_ => {
push(Cls::Err, b"https fetch failed (handshake/timeout)");
return;
},
}
} else {
let Some(ip) = netstack::dns_resolve(host) else {
push(Cls::Err, b"could not resolve host");
return;
};
match netstack::http_get(ip, port, path, host, wb) {
Some(n) => (n, 0usize),
None => {
push(Cls::Err, b"fetch failed (connect/non-200/timeout)");
return;
},
}
};
if render {
use bring_browser::{layout, Page};
static mut PG: Page = Page::new();
let pg = unsafe { &mut *&raw mut PG };
layout(&wb[off..len], COLS - 2, pg);
for l in pg.lines[..pg.line_count].iter() {
let cls = if l.link != u8::MAX { Cls::Accent } else { Cls::Normal };
push(cls, l.text());
}
} else {
push_wrapped(Cls::Normal, &wb[off..len]);
}
}
fn w2(buf: &mut [u8], v: u32) -> usize {
buf[0] = b'0' + ((v / 10) % 10) as u8;
buf[1] = b'0' + (v % 10) as u8;
2
}
fn w4(buf: &mut [u8], v: u32) -> usize {
buf[0] = b'0' + ((v / 1000) % 10) as u8;
buf[1] = b'0' + ((v / 100) % 10) as u8;
buf[2] = b'0' + ((v / 10) % 10) as u8;
buf[3] = b'0' + (v % 10) as u8;
4
}
fn w_any(buf: &mut [u8], mut v: u32) -> usize {
if v == 0 {
buf[0] = b'0';
return 1;
}
let mut t = [0u8; 10];
let mut k = 0;
while v > 0 {
t[k] = b'0' + (v % 10) as u8;
v /= 10;
k += 1;
}
for i in 0..k {
buf[i] = t[k - 1 - i];
}
k
}
/// While collecting a sudo/su password: masked entry, Enter submits, Esc
/// cancels, Backspace edits. Returns true if the key was consumed (i.e. we're
/// in password mode). On success, sudo re-runs the pending command and su
/// switches the current user via `lingfs::set_current_user`.
fn handle_pw_key(k: u8) -> bool {
unsafe {
if PW_MODE == 0 {
return false;
}
match k {
b'\n' | b'\r' => {
let mode = PW_MODE;
let plen = PENDING_LEN;
let mut pend = [0u8; INPUT_MAX];
pend[..plen].copy_from_slice(&(&*&raw const PENDING)[..plen]);
let pw = core::str::from_utf8(&(&*&raw const PW_INPUT)[..PW_LEN]).unwrap_or("");
if mode == 1 {
let user = lingfs::current_user();
if users::verify(user, pw) {
push(Cls::Dim, b"[sudo] ok");
PW_MODE = 0;
PW_LEN = 0;
PENDING_LEN = 0;
run(core::str::from_utf8(&pend[..plen]).unwrap_or(""));
return true;
}
push(Cls::Err, b"sudo: authentication failure");
} else if mode == 2 {
let target = core::str::from_utf8(&pend[..plen]).unwrap_or("root");
if users::verify(target, pw) {
let mut gb = [0u8; 32];
let glen = users::group_of(target, &mut gb).unwrap_or(0);
let grp = core::str::from_utf8(&gb[..glen]).unwrap_or("users");
lingfs::set_current_user(target, grp);
let mut m = [0u8; 48];
let p = b"now logged in as ";
m[..p.len()].copy_from_slice(p);
let tn = target.len().min(m.len() - p.len());
m[p.len()..p.len() + tn].copy_from_slice(&target.as_bytes()[..tn]);
push(Cls::Ok, &m[..p.len() + tn]);
} else {
push(Cls::Err, b"su: authentication failure");
}
}
PW_MODE = 0;
PW_LEN = 0;
PENDING_LEN = 0;
},
0x08 => {
if PW_LEN > 0 {
PW_LEN -= 1;
}
},
0x1B => {
PW_MODE = 0;
PW_LEN = 0;
PENDING_LEN = 0;
push(Cls::Dim, b"(cancelled)");
},
0x20..=0x7E => {
if PW_LEN < INPUT_MAX {
PW_INPUT[PW_LEN] = k;
PW_LEN += 1;
}
},
_ => {},
}
true
}
}
pub fn key(k: u8) {
use crate::drivers::keyboard as kb;
unsafe {
if !STARTED {
STARTED = true;
banner();
}
// While a sudo/su password is being collected, all keys feed the
// masked entry, not the command line.
if handle_pw_key(k) {
return;
}
match k {
kb::CTRL_ZOOM_IN => ZOOM = (ZOOM + 1).min(3),
kb::CTRL_ZOOM_OUT => ZOOM = (ZOOM - 1).max(1),
kb::CTRL_ZOOM_RESET => ZOOM = 1,
kb::CTRL_C => {
// Copy the last output line to the clipboard.
if COUNT > 0 {
let last = (HEAD + LINES - 1) % LINES;
clipboard::set(&rows()[last].text[..rows()[last].len]);
}
},
kb::CTRL_V => {
let clip = clipboard::get();
for &b in clip {
if b >= 0x20 && b < 0x7F && INPUT_LEN < INPUT_MAX {
INPUT[INPUT_LEN] = b;
INPUT_LEN += 1;
}
}
},
// Up/Down walk command history (like a real shell), not the
// scrollback -- new output already snaps the view to the bottom.
kb::UP_ARROW => history_recall(1),
kb::DOWN_ARROW => history_recall(-1),
b'\n' | b'\r' => {
prompt_echo();
let mut tmp = [0u8; INPUT_MAX];
let n = INPUT_LEN;
tmp[..n].copy_from_slice(&(&*&raw const INPUT)[..n]);
INPUT_LEN = 0;
history_push(&tmp[..n]);
run(core::str::from_utf8(&tmp[..n]).unwrap_or(""));
},
0x08 => {
if INPUT_LEN > 0 {
INPUT_LEN -= 1;
}
},
0x20..=0x7E => {
if INPUT_LEN < INPUT_MAX {
INPUT[INPUT_LEN] = k;
INPUT_LEN += 1;
}
},
_ => {},
}
}
}
fn cls_color(c: Cls) -> u32 {
match c {
Cls::Accent => theme::color(theme::SLOT_ACCENT),
Cls::Ok => 0x6AC06A,
Cls::Err => theme::color(theme::SLOT_ERROR),
Cls::Dim => theme::color(theme::SLOT_DIM),
Cls::Normal => theme::color(theme::SLOT_TEXT),
}
}
/// Draw the terminal into a window content rect. Grid geometry follows the
/// zoom: bigger glyphs => fewer rows/cols => lower text resolution.
pub fn draw(x: u32, y: u32, w: u32, h: u32) {
let panel = theme::color(theme::SLOT_PANEL);
unsafe {
if !STARTED {
STARTED = true;
banner();
}
}
let z = unsafe { ZOOM };
let gw = 8 * z;
let gh = 8 * z + 2 * z;
let cols = ((w / gw).max(1)) as usize;
pump_llm(cols.saturating_sub(1));
// Reserve the last grid row for the input prompt.
let grid_rows = (h / gh).max(2) as usize;
let out_rows = grid_rows - 1;
let count = unsafe { COUNT };
let head = unsafe { HEAD };
let scroll = unsafe { SCROLL }.min(count.saturating_sub(1));
// Bottom visible row is (count-1-scroll); show `out_rows` up from there.
let last = count.saturating_sub(1).saturating_sub(scroll);
let first = last.saturating_sub(out_rows - 1);
let mut ry = y;
for vis in first..=last {
if count == 0 {
break;
}
// ring index of logical row `vis` (0 = oldest kept)
let ring = (head + LINES - count + vis) % LINES;
let r = &rows()[ring];
let color = cls_color(r.cls);
let n = r.len.min(cols);
for (ci, &ch) in r.text[..n].iter().enumerate() {
font8x8::draw_char_scaled(x + ci as u32 * gw, ry, ch, color, panel, z);
}
ry += gh;
}
// Input prompt on the bottom row. In sudo/su password mode, show a
// "password:" label and mask the entry with asterisks.
let py = y + (grid_rows as u32 - 1) * gh;
let accent = theme::color(theme::SLOT_ACCENT);
let txt = theme::color(theme::SLOT_TEXT);
let pw_mode = unsafe { PW_MODE };
if pw_mode != 0 {
let label: &[u8] = b"password:";
for (ci, &ch) in label.iter().enumerate() {
font8x8::draw_char_scaled(x + ci as u32 * gw, py, ch, theme::color(theme::SLOT_DIM), panel, z);
}
let pl = unsafe { PW_LEN };
let base = label.len() as u32 + 1;
let shown = pl.min(cols.saturating_sub(base as usize + 1));
for i in 0..shown {
font8x8::draw_char_scaled(x + (base + i as u32) * gw, py, b'*', txt, panel, z);
}
framebuffer::back_fill_rect(x + (base + shown as u32) * gw, py, gw / 4 + 1, gh, accent);
} else {
font8x8::draw_char_scaled(x, py, b'>', accent, panel, z);
let inp = unsafe { &(&*&raw const INPUT)[..INPUT_LEN] };
let shown = inp.len().min(cols.saturating_sub(3));
let start = inp.len() - shown;
for (ci, &ch) in inp[start..].iter().enumerate() {
font8x8::draw_char_scaled(x + (ci as u32 + 2) * gw, py, ch, txt, panel, z);
}
framebuffer::back_fill_rect(x + (shown as u32 + 2) * gw, py, gw / 4 + 1, gh, accent);
}
let _ = input_str;
}