From 771c553571e89ab0be9f8951fde92a8c763ef9eb Mon Sep 17 00:00:00 2001
From: Mark Poliakov <mark@alnyan.me>
Date: Sun, 2 Mar 2025 17:27:26 +0200
Subject: [PATCH] term/sysutils: alternate mode, cursor hide/show, top-like
 utility

---
 userspace/Cargo.lock                          |  20 ++
 userspace/Cargo.toml                          |   1 +
 userspace/lib/cross/src/signal.rs             |   7 +-
 userspace/lib/cross/src/sys/unix/mem.rs       |  12 +-
 userspace/lib/cross/src/sys/unix/mod.rs       |  11 +-
 userspace/lib/cross/src/sys/unix/pid.rs       |   5 +-
 userspace/lib/cross/src/sys/unix/pipe.rs      |   5 +-
 userspace/lib/cross/src/sys/unix/timer.rs     |  22 +-
 userspace/lib/cross/src/sys/yggdrasil/mod.rs  |  30 +-
 .../lib/cross/src/sys/yggdrasil/socket.rs     |  19 +-
 userspace/lib/libterm/Cargo.toml              |   8 +
 userspace/lib/libterm/src/lib.rs              |  11 +-
 userspace/lib/libterm/src/tui.rs              | 107 +++++++
 userspace/sysutils/Cargo.toml                 |   7 +-
 userspace/sysutils/src/lib.rs                 |  23 --
 userspace/sysutils/src/top.rs                 | 301 ++++++++++++++++++
 userspace/sysutils/src/tst.rs                 |   2 +-
 userspace/term/src/main.rs                    |  17 +-
 userspace/term/src/state.rs                   |  56 +++-
 xtask/src/build/userspace.rs                  |   1 +
 20 files changed, 582 insertions(+), 83 deletions(-)
 create mode 100644 userspace/lib/libterm/src/tui.rs
 create mode 100644 userspace/sysutils/src/top.rs

diff --git a/userspace/Cargo.lock b/userspace/Cargo.lock
index 08cd4320..81f49a52 100644
--- a/userspace/Cargo.lock
+++ b/userspace/Cargo.lock
@@ -285,6 +285,12 @@ dependencies = [
  "wayland-client",
 ]
 
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
 [[package]]
 name = "cc"
 version = "1.2.14"
@@ -1359,6 +1365,7 @@ version = "0.1.0"
 dependencies = [
  "libc",
  "thiserror",
+ "tui",
  "yggdrasil-rt",
 ]
 
@@ -2690,6 +2697,7 @@ dependencies = [
  "serde_json",
  "sha2",
  "thiserror",
+ "tui",
  "yggdrasil-abi",
  "yggdrasil-rt",
 ]
@@ -2867,6 +2875,18 @@ version = "0.25.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
 
+[[package]]
+name = "tui"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccdd26cbd674007e649a272da4475fb666d3aa0ad0531da7136db6fab0e5bad1"
+dependencies = [
+ "bitflags 1.3.2",
+ "cassowary",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
 [[package]]
 name = "typed-arena"
 version = "2.0.2"
diff --git a/userspace/Cargo.toml b/userspace/Cargo.toml
index 24dec191..5013b78b 100644
--- a/userspace/Cargo.toml
+++ b/userspace/Cargo.toml
@@ -37,6 +37,7 @@ env_logger = "0.11.5"
 sha2 = { version = "0.10.8" }
 chrono = { version = "0.4.31", default-features = false }
 postcard = { version = "1.1.1", features = ["alloc"] }
+tui = { version = "0.19.0", default-features = false }
 
 raqote = { version = "0.8.3", default-features = false }
 
diff --git a/userspace/lib/cross/src/signal.rs b/userspace/lib/cross/src/signal.rs
index 9a67e22a..f2dff0ff 100644
--- a/userspace/lib/cross/src/signal.rs
+++ b/userspace/lib/cross/src/signal.rs
@@ -1,6 +1,11 @@
-use crate::sys;
+use std::io;
 
+use crate::sys;
 
 pub fn set_sigint_handler(handler: fn()) {
     sys::set_sigint_handler(handler);
 }
+
+pub fn send_kill(pid: u32) -> io::Result<()> {
+    sys::send_kill(pid)
+}
diff --git a/userspace/lib/cross/src/sys/unix/mem.rs b/userspace/lib/cross/src/sys/unix/mem.rs
index c0ab126a..dfe87ebd 100644
--- a/userspace/lib/cross/src/sys/unix/mem.rs
+++ b/userspace/lib/cross/src/sys/unix/mem.rs
@@ -52,16 +52,8 @@ impl sys::FileMapping for FileMappingImpl {
         if write {
             flags |= libc::PROT_WRITE;
         }
-        let pointer = unsafe {
-            libc::mmap(
-                null_mut(),
-                size,
-                flags,
-                libc::MAP_SHARED,
-                fd.as_raw_fd(),
-                0,
-            )
-        };
+        let pointer =
+            unsafe { libc::mmap(null_mut(), size, flags, libc::MAP_SHARED, fd.as_raw_fd(), 0) };
         if pointer == libc::MAP_FAILED {
             return Err(io::Error::last_os_error());
         }
diff --git a/userspace/lib/cross/src/sys/unix/mod.rs b/userspace/lib/cross/src/sys/unix/mod.rs
index 0327df77..6e3a52a2 100644
--- a/userspace/lib/cross/src/sys/unix/mod.rs
+++ b/userspace/lib/cross/src/sys/unix/mod.rs
@@ -6,7 +6,7 @@ pub mod socket;
 pub mod term;
 pub mod timer;
 
-use std::{ffi::c_int, sync::Mutex};
+use std::{ffi::c_int, io, sync::Mutex};
 
 pub use mem::{FileMappingImpl, SharedMemoryImpl};
 pub use pid::PidFdImpl;
@@ -29,3 +29,12 @@ pub fn set_sigint_handler(handler: fn()) {
     *SIGINT_HANDLER.lock().unwrap() = handler;
     unsafe { libc::signal(libc::SIGINT, sigint_proxy as usize) };
 }
+
+pub fn send_kill(pid: u32) -> io::Result<()> {
+    let res = unsafe { libc::kill(pid as c_int, libc::SIGKILL) };
+    if res == 0 {
+        Ok(())
+    } else {
+        Err(io::Error::last_os_error())
+    }
+}
diff --git a/userspace/lib/cross/src/sys/unix/pid.rs b/userspace/lib/cross/src/sys/unix/pid.rs
index ebed1cf2..f7bafb79 100644
--- a/userspace/lib/cross/src/sys/unix/pid.rs
+++ b/userspace/lib/cross/src/sys/unix/pid.rs
@@ -1,4 +1,7 @@
-use std::{io, os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}};
+use std::{
+    io,
+    os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
+};
 
 use crate::sys::PidFd;
 
diff --git a/userspace/lib/cross/src/sys/unix/pipe.rs b/userspace/lib/cross/src/sys/unix/pipe.rs
index c87cd6a1..5b597671 100644
--- a/userspace/lib/cross/src/sys/unix/pipe.rs
+++ b/userspace/lib/cross/src/sys/unix/pipe.rs
@@ -1,4 +1,7 @@
-use std::{io::{self, Read, Write}, os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}};
+use std::{
+    io::{self, Read, Write},
+    os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
+};
 
 use crate::sys::Pipe;
 
diff --git a/userspace/lib/cross/src/sys/unix/timer.rs b/userspace/lib/cross/src/sys/unix/timer.rs
index b81df4ee..2f0bc519 100644
--- a/userspace/lib/cross/src/sys/unix/timer.rs
+++ b/userspace/lib/cross/src/sys/unix/timer.rs
@@ -1,5 +1,9 @@
 use std::{
-    io, mem::size_of, os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, ptr::null_mut, time::Duration
+    io,
+    mem::size_of,
+    os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
+    ptr::null_mut,
+    time::Duration,
 };
 
 use crate::sys::TimerFd;
@@ -10,24 +14,24 @@ pub struct TimerFdImpl {
 
 impl TimerFd for TimerFdImpl {
     fn new() -> io::Result<Self> {
-        let fd = unsafe { libc::timerfd_create(libc::CLOCK_MONOTONIC, libc::TFD_NONBLOCK | libc::TFD_CLOEXEC) };
+        let fd = unsafe {
+            libc::timerfd_create(
+                libc::CLOCK_MONOTONIC,
+                libc::TFD_NONBLOCK | libc::TFD_CLOEXEC,
+            )
+        };
         if fd < 0 {
             return Err(io::Error::last_os_error());
         }
         let fd = unsafe { OwnedFd::from_raw_fd(fd) };
-        Ok(Self {
-            fd
-        })
+        Ok(Self { fd })
     }
 
     fn start(&mut self, timeout: Duration) -> io::Result<()> {
         let tv_sec = timeout.as_secs() as _;
         let tv_nsec = timeout.subsec_nanos().into();
         let spec = libc::itimerspec {
-            it_value: libc::timespec {
-                tv_sec,
-                tv_nsec,
-            },
+            it_value: libc::timespec { tv_sec, tv_nsec },
             it_interval: libc::timespec {
                 tv_sec: 0,
                 tv_nsec: 0,
diff --git a/userspace/lib/cross/src/sys/yggdrasil/mod.rs b/userspace/lib/cross/src/sys/yggdrasil/mod.rs
index fb5e0a62..ada4edc8 100644
--- a/userspace/lib/cross/src/sys/yggdrasil/mod.rs
+++ b/userspace/lib/cross/src/sys/yggdrasil/mod.rs
@@ -1,18 +1,30 @@
-pub mod poll;
-pub mod timer;
+pub mod mem;
 pub mod pid;
 pub mod pipe;
-pub mod term;
+pub mod poll;
 pub mod socket;
-pub mod mem;
+pub mod term;
+pub mod timer;
 
-pub use poll::PollImpl;
-pub use timer::TimerFdImpl;
+use std::io;
+
+pub use mem::{FileMappingImpl, SharedMemoryImpl};
 pub use pid::PidFdImpl;
 pub use pipe::PipeImpl;
+pub use poll::PollImpl;
+pub use socket::{BorrowedAddressImpl, LocalPacketSocketImpl, OwnedAddressImpl};
 pub use term::RawStdinImpl;
-pub use socket::{LocalPacketSocketImpl, OwnedAddressImpl, BorrowedAddressImpl};
-pub use mem::{SharedMemoryImpl, FileMappingImpl};
+pub use timer::TimerFdImpl;
 
-pub fn set_sigint_handler(_handler: fn()) {
+pub fn set_sigint_handler(_handler: fn()) {}
+
+pub fn send_kill(pid: u32) -> io::Result<()> {
+    use runtime::rt::{
+        process::{ProcessId, Signal},
+        sys as syscall,
+    };
+
+    let target = unsafe { ProcessId::from_raw(pid) };
+    unsafe { syscall::send_signal(target, Signal::Killed) }.map_err(io::Error::from)?;
+    Ok(())
 }
diff --git a/userspace/lib/cross/src/sys/yggdrasil/socket.rs b/userspace/lib/cross/src/sys/yggdrasil/socket.rs
index 38d183a2..c5e7a4e6 100644
--- a/userspace/lib/cross/src/sys/yggdrasil/socket.rs
+++ b/userspace/lib/cross/src/sys/yggdrasil/socket.rs
@@ -30,7 +30,6 @@ pub enum OwnedAddressImpl {
     Anonymous(u64),
 }
 
-
 impl<'a> BorrowedAddressImpl<'a> {
     pub fn to_sys(&self) -> LocalSocketAddress<'a> {
         match *self {
@@ -59,14 +58,14 @@ impl sys::OwnedAddress for OwnedAddressImpl {
     fn as_borrowed(&self) -> Self::Borrowed<'_> {
         match self {
             Self::Named(path) => BorrowedAddressImpl::Named(path.as_path()),
-            &Self::Anonymous(id) => BorrowedAddressImpl::Anonymous(id)
+            &Self::Anonymous(id) => BorrowedAddressImpl::Anonymous(id),
         }
     }
 
     fn from_borrowed(borrowed: &Self::Borrowed<'_>) -> Self {
         match *borrowed {
             BorrowedAddressImpl::Named(path) => Self::Named(path.into()),
-            BorrowedAddressImpl::Anonymous(id) => Self::Anonymous(id)
+            BorrowedAddressImpl::Anonymous(id) => Self::Anonymous(id),
         }
     }
 }
@@ -127,7 +126,7 @@ impl LocalPacketSocket for LocalPacketSocketImpl {
             source: Some(&mut address),
             payload: data,
             ancillary: None,
-            ancillary_len: 0
+            ancillary_len: 0,
         };
         let len = unsafe { yggdrasil_rt::sys::receive_message(self.as_raw_fd(), &mut message) }?;
         let address: LocalSocketAddress =
@@ -135,16 +134,13 @@ impl LocalPacketSocket for LocalPacketSocketImpl {
         Ok((len, OwnedAddressImpl::from_sys(&address)))
     }
 
-    fn receive_with_ancillary(
-        &self,
-        data: &mut [u8],
-    ) -> io::Result<(usize, Option<OwnedFd>)> {
+    fn receive_with_ancillary(&self, data: &mut [u8]) -> io::Result<(usize, Option<OwnedFd>)> {
         let mut ancillary = [0; 32];
         let mut message = MessageHeaderMut {
             source: None,
             payload: data,
             ancillary: Some(&mut ancillary),
-            ancillary_len: 0
+            ancillary_len: 0,
         };
         let len = unsafe { yggdrasil_rt::sys::receive_message(self.as_raw_fd(), &mut message) }?;
         let anc_len = message.ancillary_len;
@@ -162,11 +158,12 @@ impl LocalPacketSocket for LocalPacketSocketImpl {
             source: Some(&mut address),
             payload: data,
             ancillary: Some(&mut ancillary),
-            ancillary_len: 0
+            ancillary_len: 0,
         };
         let len = unsafe { yggdrasil_rt::sys::receive_message(self.as_raw_fd(), &mut message) }?;
         let anc_len = message.ancillary_len;
-        let address: LocalSocketAddress = wire::from_slice(&address).map_err(yggdrasil_rt::Error::from)?;
+        let address: LocalSocketAddress =
+            wire::from_slice(&address).map_err(yggdrasil_rt::Error::from)?;
         let address = OwnedAddressImpl::from_sys(&address);
         let fd = read_ancillary(&ancillary[..anc_len])?;
         Ok((len, fd, address))
diff --git a/userspace/lib/libterm/Cargo.toml b/userspace/lib/libterm/Cargo.toml
index b523352a..ebca0d21 100644
--- a/userspace/lib/libterm/Cargo.toml
+++ b/userspace/lib/libterm/Cargo.toml
@@ -8,11 +8,19 @@ edition = "2021"
 [dependencies]
 thiserror.workspace = true
 
+tui = { workspace = true, optional = true }
+
 [target.'cfg(unix)'.dependencies]
 libc = "0.2.150"
 
 [target.'cfg(target_os = "yggdrasil")'.dependencies]
 yggdrasil-rt.workspace = true
 
+[dev-dependencies]
+tui.workspace = true
+
+[features]
+default = []
+
 [lints]
 workspace = true
diff --git a/userspace/lib/libterm/src/lib.rs b/userspace/lib/libterm/src/lib.rs
index d8d8c95c..a9003db1 100644
--- a/userspace/lib/libterm/src/lib.rs
+++ b/userspace/lib/libterm/src/lib.rs
@@ -1,7 +1,10 @@
 #![cfg_attr(target_os = "yggdrasil", feature(yggdrasil_os, rustc_private))]
 
 use std::{
-    fmt, fs::File, io::{self, stdin, stdout, IsTerminal, Read, Stdin, Stdout, Write}, os::fd::{AsRawFd, RawFd}
+    fmt,
+    fs::File,
+    io::{self, stdin, stdout, IsTerminal, Read, Stdin, Stdout, Write},
+    os::fd::{AsRawFd, RawFd},
 };
 
 pub use self::{input::ReadChar, sys::RawMode};
@@ -17,6 +20,8 @@ mod unix;
 use unix as sys;
 
 mod input;
+#[cfg(any(feature = "tui", rust_analyzer))]
+mod tui;
 
 pub use input::{InputError, TermKey};
 
@@ -43,14 +48,14 @@ pub trait RawTerminal {
 
 enum TermInput {
     Stdin(Stdin),
-    File(File)
+    File(File),
 }
 
 impl Read for TermInput {
     fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
         match self {
             Self::Stdin(stdin) => stdin.read(buf),
-            Self::File(file) => file.read(buf)
+            Self::File(file) => file.read(buf),
         }
     }
 }
diff --git a/userspace/lib/libterm/src/tui.rs b/userspace/lib/libterm/src/tui.rs
new file mode 100644
index 00000000..57558371
--- /dev/null
+++ b/userspace/lib/libterm/src/tui.rs
@@ -0,0 +1,107 @@
+use std::io::{self, Write};
+
+use tui::{buffer, layout::Rect, style::Color};
+
+use crate::{Color as TColor, RawTerminal, Term};
+
+fn to_attributes(color: Color) -> Option<(TColor, bool)> {
+    Some(match color {
+        Color::Reset | Color::Indexed(_) | Color::Rgb(_, _, _) => return None,
+        Color::Red => (TColor::Red, false),
+        Color::LightRed => (TColor::Red, true),
+        Color::Green => (TColor::Green, false),
+        Color::LightGreen => (TColor::Green, true),
+        Color::Blue => (TColor::Blue, false),
+        Color::LightBlue => (TColor::Blue, true),
+        Color::Yellow => (TColor::Yellow, false),
+        Color::LightYellow => (TColor::Yellow, true),
+        Color::Magenta => (TColor::Magenta, false),
+        Color::LightMagenta => (TColor::Magenta, true),
+        Color::Cyan => (TColor::Cyan, false),
+        Color::LightCyan => (TColor::Cyan, true),
+        Color::Gray => (TColor::White, false),
+        Color::White => (TColor::White, true),
+        Color::Black => (TColor::Black, false),
+        Color::DarkGray => (TColor::Black, true),
+    })
+}
+
+impl tui::backend::Backend for Term {
+    fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>
+    where
+        I: Iterator<Item = (u16, u16, &'a buffer::Cell)>,
+    {
+        let mut fg = Color::Reset;
+        let mut bg = Color::Reset;
+        let mut last_pos: Option<(u16, u16)> = None;
+
+        for (x, y, cell) in content {
+            if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
+                self.stdout.raw_move_cursor(y as usize, x as usize)?;
+            }
+            last_pos = Some((x, y));
+            if cell.fg != fg {
+                if let Some((color, bold)) = to_attributes(cell.fg) {
+                    if bold {
+                        self.stdout.raw_set_style(1)?;
+                    } else {
+                        self.stdout.raw_set_style(0)?;
+                    }
+                    self.stdout.raw_set_color(3, color)?;
+                } else {
+                    self.stdout.raw_set_style(0)?;
+                }
+            }
+            if cell.bg != bg || cell.fg != fg {
+                if let Some((color, _)) = to_attributes(cell.bg) {
+                    self.stdout.raw_set_color(4, color)?;
+                } else {
+                    self.stdout.raw_set_color(4, TColor::Black)?;
+                }
+            }
+
+            fg = cell.fg;
+            bg = cell.bg;
+
+            self.stdout.write_all(cell.symbol.as_bytes())?;
+        }
+
+        self.stdout.raw_set_style(0)?;
+
+        Ok(())
+    }
+
+    fn clear(&mut self) -> Result<(), io::Error> {
+        self.stdout.raw_clear_all()
+    }
+
+    fn flush(&mut self) -> Result<(), io::Error> {
+        self.stdout.flush()
+    }
+
+    fn size(&self) -> Result<Rect, io::Error> {
+        let (w, h) = self.stdout.raw_size()?;
+        Ok(Rect {
+            x: 0,
+            y: 0,
+            width: w as u16,
+            height: h as u16 - 1,
+        })
+    }
+
+    fn show_cursor(&mut self) -> Result<(), io::Error> {
+        self.stdout.raw_set_cursor_visible(true)
+    }
+
+    fn hide_cursor(&mut self) -> Result<(), io::Error> {
+        self.stdout.raw_set_cursor_visible(false)
+    }
+
+    fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error> {
+        self.stdout.raw_move_cursor(y as usize, x as usize)
+    }
+
+    fn get_cursor(&mut self) -> Result<(u16, u16), io::Error> {
+        Ok((0, 0))
+    }
+}
diff --git a/userspace/sysutils/Cargo.toml b/userspace/sysutils/Cargo.toml
index 9b17d251..9c25671b 100644
--- a/userspace/sysutils/Cargo.toml
+++ b/userspace/sysutils/Cargo.toml
@@ -7,7 +7,7 @@ authors = ["Mark Poliakov <mark@alnyan.me>"]
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
-libterm.workspace = true
+libterm = { workspace = true, features = ["tui"] }
 cross.workspace = true
 logsink.workspace = true
 libutil.workspace = true
@@ -20,6 +20,7 @@ serde.workspace = true
 serde_json.workspace = true
 sha2.workspace = true
 chrono.workspace = true
+tui.workspace = true
 
 # Own regex implementation?
 regex = "1.11.1"
@@ -137,6 +138,10 @@ path = "src/lspci.rs"
 name = "ps"
 path = "src/ps.rs"
 
+[[bin]]
+name = "top"
+path = "src/top.rs"
+
 [[bin]]
 name = "tst"
 path = "src/tst.rs"
diff --git a/userspace/sysutils/src/lib.rs b/userspace/sysutils/src/lib.rs
index f0e60b8d..cb015217 100644
--- a/userspace/sysutils/src/lib.rs
+++ b/userspace/sysutils/src/lib.rs
@@ -48,34 +48,11 @@ pub mod unix {
     }
 }
 
-// pub struct Syslog {
-//     #[cfg(target_os = "yggdrasil")]
-//     channel: std::os::yggdrasil::io::message_channel::MessageChannel,
-// }
-
 // TODO replace this
 pub trait ToExitCode {
     fn to_exit_code(&self) -> i32;
 }
 
-// #[cfg(target_os = "yggdrasil")]
-// impl Syslog {
-//     pub fn open() -> io::Result<Self> {
-//         use std::os::yggdrasil::io::message_channel::MessageChannel;
-//
-//         let channel = MessageChannel::open("log", false)?;
-//
-//         Ok(Self { channel })
-//     }
-//
-//     pub fn send_str(&mut self, msg: &str) -> io::Result<()> {
-//         use std::os::yggdrasil::io::message_channel::{MessageDestination, MessageSender};
-//
-//         self.channel
-//             .send_message(msg.as_bytes(), MessageDestination::Specific(0))
-//     }
-// }
-
 impl<T> ToExitCode for io::Result<T> {
     fn to_exit_code(&self) -> i32 {
         match self {
diff --git a/userspace/sysutils/src/top.rs b/userspace/sysutils/src/top.rs
new file mode 100644
index 00000000..eef9e296
--- /dev/null
+++ b/userspace/sysutils/src/top.rs
@@ -0,0 +1,301 @@
+#![feature(yggdrasil_os)]
+use std::{
+    collections::HashSet,
+    fs, io,
+    os::fd::AsRawFd,
+    path::{Path, PathBuf},
+    time::Duration,
+};
+
+use cross::io::{Poll, TimerFd};
+use libterm::{Term, TermKey};
+use tui::{
+    layout::{Constraint, Layout},
+    style::{Color, Style},
+    text::{Span, Spans},
+    widgets::{Cell, Paragraph, Row, Table},
+    Frame, Terminal,
+};
+
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+enum Error {
+    Io(#[from] io::Error),
+    Term(#[from] libterm::Error),
+}
+
+struct ProcessInfo {
+    pid: u32,
+    name: String,
+    threads: Vec<ThreadInfo>,
+}
+
+struct ThreadInfo {
+    tid: u32,
+    name: String,
+}
+
+struct State {
+    processes: Vec<ProcessInfo>,
+    expanded: HashSet<u32>,
+    selection: usize,
+}
+
+fn list_ids<P: AsRef<Path>>(path: P) -> io::Result<Vec<u32>> {
+    let mut list = vec![];
+    for entry in fs::read_dir(path)? {
+        let Ok(entry) = entry else {
+            continue;
+        };
+        let filename = entry.file_name();
+        let Some(name) = filename.to_str() else {
+            continue;
+        };
+        let Ok(pid) = name.parse() else {
+            continue;
+        };
+        list.push(pid);
+    }
+    Ok(list)
+}
+
+fn process_info(pid: u32) -> io::Result<ProcessInfo> {
+    let base = PathBuf::from("/sys/proc").join(format!("{pid}"));
+    let name = fs::read_to_string(base.join("name"))?;
+    let threads = threads(base).unwrap_or_default();
+    Ok(ProcessInfo { pid, name, threads })
+}
+
+fn processes() -> io::Result<Vec<ProcessInfo>> {
+    let pids = list_ids("/sys/proc")?;
+    let processes = pids
+        .into_iter()
+        .filter_map(|pid| process_info(pid).ok())
+        .collect();
+    Ok(processes)
+}
+
+fn thread_info<P: AsRef<Path>>(pid_path: P, tid: u32) -> io::Result<ThreadInfo> {
+    let base = pid_path.as_ref().join(format!("{tid}"));
+    let name = fs::read_to_string(base.join("name"))?;
+    Ok(ThreadInfo { tid, name })
+}
+
+fn threads<P: AsRef<Path>>(pid_path: P) -> io::Result<Vec<ThreadInfo>> {
+    let pid_path = pid_path.as_ref();
+    let tids = list_ids(pid_path)?;
+    let threads = tids
+        .into_iter()
+        .filter_map(|tid| thread_info(pid_path, tid).ok())
+        .collect();
+    Ok(threads)
+}
+
+impl State {
+    fn new() -> Result<Self, Error> {
+        let processes = processes()?;
+        Ok(Self {
+            processes,
+            expanded: HashSet::new(),
+            selection: 0,
+        })
+    }
+
+    fn next(&mut self) {
+        if self.processes.is_empty() {
+            self.selection = 0;
+            return;
+        }
+        if self.selection < self.processes.len() - 1 {
+            self.selection += 1;
+        }
+    }
+
+    fn prev(&mut self) {
+        if self.selection > 0 {
+            self.selection -= 1;
+        }
+    }
+
+    fn toggle_selected(&mut self) {
+        if let Some(selected) = self.processes.get(self.selection) {
+            let pid = selected.pid;
+            if self.expanded.contains(&pid) {
+                self.expanded.remove(&pid);
+            } else {
+                self.expanded.insert(pid);
+            }
+        }
+    }
+
+    fn kill_selected(&self) -> Result<(), Error> {
+        if let Some(selected) = self.processes.get(self.selection) {
+            cross::signal::send_kill(selected.pid)?;
+        }
+        Ok(())
+    }
+
+    fn refresh(&mut self) -> Result<(), Error> {
+        let selected_pid = self
+            .processes
+            .get(self.selection)
+            .map(|process| process.pid);
+        self.processes = processes()?;
+        let new_selection = selected_pid
+            .and_then(|pid| self.processes.iter().position(|process| process.pid == pid));
+        self.selection = new_selection.unwrap_or(0);
+
+        Ok(())
+    }
+
+    fn render(&self, f: &mut Frame<Term>) {
+        const KEYS: &[(&str, &str)] = &[
+            (" j ", "Down"),
+            (" k ", "Up"),
+            (" Enter ", "Toggle"),
+            (" K ", "Kill"),
+        ];
+
+        let rects = Layout::default()
+            .constraints([Constraint::Percentage(99), Constraint::Min(1)].as_ref())
+            .split(f.size());
+
+        let normal_style = Style::default();
+        let selected_style = Style::default().bg(Color::Cyan).fg(Color::Black);
+        let hint_style = Style::default().bg(Color::Blue).fg(Color::White);
+        let key_style = Style::default().bg(Color::Cyan).fg(Color::Black);
+
+        let rows = self
+            .processes
+            .iter()
+            .enumerate()
+            .map(|(i, process)| {
+                let style = if i == self.selection {
+                    selected_style
+                } else {
+                    normal_style
+                };
+
+                let mut rows = vec![Row::new(vec![
+                    Cell::from(format!("{}", process.pid)),
+                    Cell::from(""),
+                    Cell::from(process.name.as_str()),
+                ])
+                .height(1)
+                .style(style)];
+
+                if self.expanded.contains(&process.pid) {
+                    let count = process.threads.len();
+                    for (i, thread) in process.threads.iter().enumerate() {
+                        let ch = if i == count - 1 { "└" } else { "├" };
+                        rows.push(
+                            Row::new(vec![
+                                Cell::from(ch),
+                                Cell::from(format!("{}", thread.tid)),
+                                Cell::from(thread.name.as_str()),
+                            ])
+                            .style(style),
+                        );
+                    }
+                }
+
+                rows
+            })
+            .flatten();
+
+        let header = Row::new(vec![
+            Cell::from("PID"),
+            Cell::from("TID"),
+            Cell::from("NAME"),
+        ])
+        .height(1)
+        .style(hint_style);
+
+        let table = Table::new(rows)
+            .widths(&[
+                Constraint::Min(6),
+                Constraint::Min(6),
+                Constraint::Percentage(100),
+            ])
+            .highlight_symbol(">>")
+            .header(header);
+
+        let mut status_spans = vec![];
+        for (i, &(key, hint)) in KEYS.iter().enumerate() {
+            if i != 0 {
+                status_spans.push(Span::from(" "));
+            }
+            status_spans.push(Span::styled(key, key_style));
+            status_spans.push(Span::styled(hint, hint_style));
+        }
+        let status = Paragraph::new(vec![Spans::from(status_spans)]);
+
+        f.render_widget(table, rects[0]);
+        f.render_widget(status, rects[1]);
+    }
+}
+
+fn run() -> Result<(), Error> {
+    let mut state = State::new()?;
+
+    let mut poll = Poll::new()?;
+    let mut timer = TimerFd::new()?;
+
+    let mut running = true;
+    let term = Term::open()?;
+    let term_fd = term.input_fd();
+
+    poll.add(&timer)?;
+    poll.add(&term_fd)?;
+
+    let mut term = Terminal::new(term)?;
+
+    timer.start(Duration::from_secs(1))?;
+
+    while running {
+        term.draw(|f| state.render(f))?;
+
+        match poll.wait(None)?.unwrap() {
+            fd if fd == timer.as_raw_fd() => {
+                timer.start(Duration::from_secs(1))?;
+                state.refresh()?;
+            }
+            fd if fd == term_fd => {
+                let key = term.backend_mut().read_key()?;
+                match key {
+                    TermKey::Escape | TermKey::Char('q') => {
+                        running = false;
+                    }
+                    TermKey::Char('\n') => {
+                        state.toggle_selected();
+                    }
+                    TermKey::Char('K') => {
+                        if let Err(error) = state.kill_selected() {
+                            log::error!("Kill: {error}");
+                        }
+                    }
+                    TermKey::Char('k') => {
+                        state.prev();
+                    }
+                    TermKey::Char('j') => {
+                        state.next();
+                    }
+                    _ => (),
+                }
+            }
+            _ => unreachable!(),
+        }
+    }
+
+    Ok(())
+}
+
+fn main() {
+    logsink::setup_logging(false);
+    match run() {
+        Ok(()) => (),
+        Err(error) => {
+            eprintln!("{error}");
+        }
+    }
+}
diff --git a/userspace/sysutils/src/tst.rs b/userspace/sysutils/src/tst.rs
index 4712eec5..b8f2719b 100644
--- a/userspace/sysutils/src/tst.rs
+++ b/userspace/sysutils/src/tst.rs
@@ -7,7 +7,7 @@ fn main() {
             .name(format!("tst-thread-{i}"))
             .spawn(move || {
                 let current = thread::current();
-                for _ in 0..10 {
+                for _ in 0..100 {
                     println!("Hi from thread {:?}", current.name());
                     thread::sleep(Duration::from_secs(1));
                 }
diff --git a/userspace/term/src/main.rs b/userspace/term/src/main.rs
index 526014dc..f93860ad 100644
--- a/userspace/term/src/main.rs
+++ b/userspace/term/src/main.rs
@@ -76,6 +76,11 @@ impl<F: Font> DrawState<F> {
     }
 
     pub fn draw(&mut self, dt: &mut [u32], state: &mut State) {
+        fn blend(x: u8, y: u8, f: f32) -> u8 {
+            let v = f * (x as f32) + (1.0 - f) * (y as f32);
+            v as u8
+        }
+
         let default_fg = state.default_attributes.fg.to_display(false).to_u32();
         let default_bg = state.default_attributes.bg.to_display(false).to_u32();
         let font_layout = self.font.layout();
@@ -93,12 +98,12 @@ impl<F: Font> DrawState<F> {
         }
 
         let scroll = state.adjust_scroll();
-        let cursor_visible = scroll == 0;
+        let cursor_visible = scroll == 0 && state.cursor_visible;
         state.buffer.visible_rows_mut(scroll, |i, row| {
             let cy = i * fh;
 
             for (j, cell) in row.cells().enumerate() {
-                let bg = cell.attrs.bg.to_display(false).to_u32();
+                let bg = cell.attrs.bg.to_display(false);
                 let fg = cell.attrs.fg.to_display(cell.attrs.bright);
 
                 let cx = j * fw;
@@ -106,7 +111,7 @@ impl<F: Font> DrawState<F> {
                 // Fill cell
                 for y in 0..fh {
                     let off = (cy + y) * self.width + cx;
-                    dt[off..off + fw].fill(bg);
+                    dt[off..off + fw].fill(bg.to_u32());
                 }
 
                 if cell.char == '\0' {
@@ -116,9 +121,9 @@ impl<F: Font> DrawState<F> {
                 let c = cell.char as char;
                 self.font.map_glyph(c, |x, y, v| {
                     let v = (v * 2.0).min(1.0);
-                    let r = fg.r as f32 * v;
-                    let g = fg.g as f32 * v;
-                    let b = fg.b as f32 * v;
+                    let r = blend(fg.r, bg.r, v);
+                    let g = blend(fg.g, bg.g, v);
+                    let b = blend(fg.b, bg.b, v);
                     let color = (b as u32) | ((g as u32) << 8) | ((r as u32) << 16) | 0xFF000000;
 
                     dt[(cy + y) * self.width + cx + x] = color;
diff --git a/userspace/term/src/state.rs b/userspace/term/src/state.rs
index ada52c4b..31f51341 100644
--- a/userspace/term/src/state.rs
+++ b/userspace/term/src/state.rs
@@ -28,10 +28,14 @@ pub struct Cursor {
     pub col: usize,
 }
 
+struct CsiState {
+    byte: u8,
+}
+
 enum EscapeState {
     Normal,
     Escape,
-    Csi,
+    Csi(CsiState),
 }
 
 #[derive(Default)]
@@ -49,6 +53,8 @@ pub struct State {
 
     scroll: usize,
     pub cursor: Cursor,
+    pub cursor_visible: bool,
+    pub alternate: bool,
     #[allow(unused)]
     saved_cursor: Option<Cursor>,
 
@@ -239,6 +245,8 @@ impl State {
             esc_state: EscapeState::Normal,
 
             cursor: Cursor { row: 0, col: 0 },
+            cursor_visible: true,
+            alternate: false,
             scroll: 0,
             saved_cursor: None,
 
@@ -298,6 +306,11 @@ impl State {
             }
         };
 
+        if self.alternate {
+            self.cursor.col = self.cursor.col.min(self.buffer.width - 1);
+            self.cursor.row = self.cursor.row.min(self.buffer.height - 1);
+        }
+
         if self.cursor.col >= self.buffer.width {
             if self.cursor.row < self.buffer.height {
                 self.buffer.rows[self.cursor.row].dirty = true;
@@ -317,8 +330,35 @@ impl State {
         redraw
     }
 
-    fn handle_ctlseq(&mut self, c: char) -> bool {
+    fn handle_ctlseq(&mut self, byte: u8, c: char) -> bool {
         let redraw = match c {
+            'h' if byte == b'?' => match self.esc_args.get(0).copied().unwrap_or(0) {
+                25 => {
+                    // Cursor visible
+                    self.cursor_visible = true;
+                    true
+                }
+                1049 => {
+                    // Enter alternate mode
+                    self.alternate = true;
+                    true
+                }
+                _ => false,
+            },
+            'l' if byte == b'?' => match self.esc_args.get(0).copied().unwrap_or(0) {
+                25 => {
+                    // Cursor not visible
+                    self.cursor_visible = false;
+                    true
+                }
+                1049 => {
+                    // Leave alternate mode
+                    self.alternate = false;
+                    true
+                }
+                _ => false,
+            },
+
             // Move back one character
             'D' => {
                 if self.cursor.col > 0 {
@@ -406,8 +446,12 @@ impl State {
         redraw
     }
 
-    fn handle_ctlseq_byte(&mut self, c: char) -> bool {
+    fn handle_ctlseq_byte(&mut self, byte: u8, c: char) -> bool {
         match c {
+            '?' if byte == 0 => {
+                self.esc_state = EscapeState::Csi(CsiState { byte: b'?' });
+                false
+            }
             c if let Some(digit) = c.to_digit(10) => {
                 let arg = self.esc_args.last_mut().unwrap();
                 *arg *= 10;
@@ -418,7 +462,7 @@ impl State {
                 self.esc_args.push(0);
                 false
             }
-            _ => self.handle_ctlseq(c),
+            _ => self.handle_ctlseq(byte, c),
         }
     }
 
@@ -428,7 +472,7 @@ impl State {
                 EscapeState::Normal => self.putc_normal(ch),
                 EscapeState::Escape => match ch {
                     '[' => {
-                        self.esc_state = EscapeState::Csi;
+                        self.esc_state = EscapeState::Csi(CsiState { byte: 0 });
                         false
                     }
                     _ => {
@@ -436,7 +480,7 @@ impl State {
                         false
                     }
                 },
-                EscapeState::Csi => self.handle_ctlseq_byte(ch),
+                EscapeState::Csi(CsiState { byte }) => self.handle_ctlseq_byte(byte, ch),
             }
         } else {
             false
diff --git a/xtask/src/build/userspace.rs b/xtask/src/build/userspace.rs
index 4fe227c9..e75c47ff 100644
--- a/xtask/src/build/userspace.rs
+++ b/xtask/src/build/userspace.rs
@@ -49,6 +49,7 @@ const PROGRAMS: &[(&str, &str)] = &[
     ("sleep", "bin/sleep"),
     ("lspci", "bin/lspci"),
     ("ps", "bin/ps"),
+    ("top", "bin/top"),
     ("tst", "bin/tst"),
     // netutils
     ("netconf", "sbin/netconf"),