mod aisling;
mod procfs;
mod scrin;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet, VecDeque};
use std::env;
use std::io;
use std::io::Write;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use aisling::Aisling;
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use procfs::{ProcessInfo, Sampler, SystemView};
use scrin::{Color, Frame, Rect, Style, Term};
const REFRESH_EVERY: Duration = Duration::from_millis(650);
const DRAW_EVERY: Duration = Duration::from_millis(100);
const IDLE_POLL: Duration = Duration::from_millis(80);
const HISTORY_LIMIT: usize = 96;
fn main() -> io::Result<()> {
match CliMode::parse(env::args().skip(1))? {
CliMode::Tui => run_tui(),
CliMode::Help => {
print_usage();
Ok(())
}
CliMode::Grep { query, limit } => run_cli_grep(&query, limit),
CliMode::Kill {
target,
signal,
yes,
} => run_cli_kill(&target, signal, yes),
CliMode::Anomalies { limit } => run_cli_anomalies(limit),
}
}
fn run_tui() -> io::Result<()> {
let mut term = Term::enter()?;
let mut app = App::new();
let (width, height) = term.size()?;
let mut frame = Frame::new(width, height, app.base_style());
app.draw_boot_loader(&mut frame, "warming procfs sampler");
term.draw(&frame)?;
app.refresh();
let mut needs_draw = true;
let mut last_draw = Instant::now() - DRAW_EVERY;
loop {
if !app.paused && app.last_refresh.elapsed() >= REFRESH_EVERY {
app.refresh();
needs_draw = true;
}
if needs_draw || last_draw.elapsed() >= DRAW_EVERY {
let (width, height) = term.size()?;
frame.resize(width, height, app.base_style());
app.draw(&mut frame);
term.draw(&frame)?;
needs_draw = false;
last_draw = Instant::now();
}
let refresh_wait = if app.paused {
IDLE_POLL
} else {
REFRESH_EVERY.saturating_sub(app.last_refresh.elapsed())
};
let draw_wait = DRAW_EVERY.saturating_sub(last_draw.elapsed());
let wait = refresh_wait.min(draw_wait).min(IDLE_POLL);
if event::poll(wait)? {
match event::read()? {
Event::Key(key) => {
if app.handle_key(key) {
break;
}
needs_draw = true;
}
Event::Mouse(mouse) => {
app.handle_mouse(mouse);
needs_draw = true;
}
_ => {}
}
}
}
Ok(())
}
enum CliMode {
Tui,
Help,
Grep {
query: String,
limit: usize,
},
Kill {
target: String,
signal: SignalKind,
yes: bool,
},
Anomalies {
limit: usize,
},
}
impl CliMode {
fn parse(args: impl Iterator<Item = String>) -> io::Result<Self> {
let mut action = CliAction::Tui;
let mut signal = SignalKind::Term;
let mut yes = false;
let mut limit = 50;
let mut saw_option = false;
let mut args = args.peekable();
if args.peek().is_none() {
return Ok(Self::Tui);
}
while let Some(arg) = args.next() {
saw_option = true;
match arg.as_str() {
"-h" | "--help" => return Ok(Self::Help),
"--grep" => action = CliAction::Grep(next_arg(&mut args, "--grep")?),
"--kill" => action = CliAction::Kill(next_arg(&mut args, "--kill")?),
"--anomalies" => action = CliAction::Anomalies,
"--signal" => {
signal =
SignalKind::parse(&next_arg(&mut args, "--signal")?).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "unknown signal")
})?;
}
"--yes" | "-y" => yes = true,
"--limit" => {
limit = next_arg(&mut args, "--limit")?
.parse::<usize>()
.map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "--limit must be a number")
})?;
}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown argument: {arg}"),
));
}
}
}
Ok(match action {
CliAction::Tui if saw_option => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"missing --grep, --kill, or --anomalies",
));
}
CliAction::Tui => Self::Tui,
CliAction::Grep(query) => Self::Grep { query, limit },
CliAction::Kill(target) => Self::Kill {
target,
signal,
yes,
},
CliAction::Anomalies => Self::Anomalies { limit },
})
}
}
enum CliAction {
Tui,
Grep(String),
Kill(String),
Anomalies,
}
fn next_arg(args: &mut impl Iterator<Item = String>, flag: &str) -> io::Result<String> {
args.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{flag} needs a value")))
}
fn print_usage() {
println!(
"ktop\n\nUSAGE:\n ktop\n ktop --grep <spotlight-query> [--limit N]\n ktop --kill <pid|spotlight-query> [--signal TERM|KILL|STOP|CONT|INT|HUP] [--yes]\n ktop --anomalies [--limit N]\n\nExamples:\n ktop --grep 'cmd:python user:root'\n ktop --kill 'cmd:firefox' --signal TERM\n ktop --kill 1234 --signal KILL --yes\n ktop --anomalies --limit 20"
);
}
fn run_cli_grep(query: &str, limit: usize) -> io::Result<()> {
let view = sample_for_query(query)?;
let query = Query::parse(query);
let mut matches = view
.processes
.iter()
.filter(|process| query.matches(process))
.collect::<Vec<_>>();
matches.sort_by(|left, right| compare_processes(left, right, SortMode::Cpu).reverse());
print_process_table(matches.into_iter().take(limit), false);
Ok(())
}
fn run_cli_anomalies(limit: usize) -> io::Result<()> {
let view = sample_with_cpu()?;
let mut matches = view
.processes
.iter()
.filter(|process| is_anomaly(process))
.collect::<Vec<_>>();
matches.sort_by(|left, right| {
anomaly_score(right)
.partial_cmp(&anomaly_score(left))
.unwrap_or(Ordering::Equal)
.then_with(|| left.pid.cmp(&right.pid))
});
print_process_table(matches.into_iter().take(limit), true);
Ok(())
}
fn run_cli_kill(target: &str, signal: SignalKind, yes: bool) -> io::Result<()> {
let mut matches = if let Some(pids) = parse_pid_targets(target) {
pids.into_iter()
.map(|pid| CliProcessTarget {
pid,
label: format!("pid {pid}"),
})
.collect::<Vec<_>>()
} else {
let view = sample_for_query(target)?;
let query = Query::parse(target);
view.processes
.iter()
.filter(|process| query.matches(process))
.map(|process| CliProcessTarget {
pid: process.pid,
label: format!("{} {}", process.name.as_ref(), process.cmd.as_ref()),
})
.collect::<Vec<_>>()
};
let self_pid = std::process::id();
matches.retain(|process| process.pid != self_pid);
if matches.is_empty() {
println!("No matching processes.");
return Ok(());
}
println!("{} matching process(es):", matches.len());
for process in matches.iter().take(20) {
println!(" {:>7} {}", process.pid, process.label);
}
if matches.len() > 20 {
println!(" ... and {} more", matches.len() - 20);
}
if !yes && !confirm_signal(signal, matches.len())? {
println!("Cancelled.");
return Ok(());
}
let mut failures = 0;
for process in &matches {
match send_signal_to_pid(process.pid, signal) {
Ok(()) => println!("sent {} to {}", signal.label(), process.pid),
Err(error) => {
failures += 1;
eprintln!("failed {}: {error}", process.pid);
}
}
}
if failures > 0 {
Err(io::Error::new(
io::ErrorKind::Other,
format!("{failures} signal(s) failed"),
))
} else {
Ok(())
}
}
struct CliProcessTarget {
pid: u32,
label: String,
}
fn sample_once() -> io::Result<SystemView> {
Sampler::new().sample()
}
fn sample_for_query(query: &str) -> io::Result<SystemView> {
if query_needs_cpu(query) {
sample_with_cpu()
} else {
sample_once()
}
}
fn sample_with_cpu() -> io::Result<SystemView> {
let mut sampler = Sampler::new();
let _ = sampler.sample()?;
thread::sleep(Duration::from_millis(220));
sampler.sample()
}
fn query_needs_cpu(query: &str) -> bool {
query.split_whitespace().any(|token| {
let token = token.trim_start_matches('!');
token.starts_with("cpu>") || token.starts_with("cpu<")
})
}
fn print_process_table<'a>(processes: impl Iterator<Item = &'a ProcessInfo>, show_score: bool) {
if show_score {
println!(" SCORE PID CPU% MEM% AGE S USER NAME CMD");
} else {
println!(" PID CPU% MEM% AGE S USER NAME CMD");
}
for process in processes {
if show_score {
print!("{:>6.1} ", anomaly_score(process));
}
println!(
"{:>6} {:>7.1} {:>6.1} {:<8} {} {:<10} {:<16} {}",
process.pid,
process.cpu,
process.mem,
format_duration_short(process.runtime),
process.state,
process.user.as_ref(),
process.name.as_ref(),
process.cmd.as_ref()
);
}
}
fn parse_pid_targets(target: &str) -> Option<Vec<u32>> {
let pids = target
.split(',')
.map(str::trim)
.map(str::parse::<u32>)
.collect::<Result<Vec<_>, _>>()
.ok()?;
(!pids.is_empty()).then_some(pids)
}
fn confirm_signal(signal: SignalKind, count: usize) -> io::Result<bool> {
print!(
"Send {} to {count} process(es)? Type yes to continue: ",
signal.label()
);
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
Ok(answer.trim() == "yes")
}
fn send_signal_to_pid(pid: u32, signal: SignalKind) -> io::Result<()> {
let status = Command::new("kill")
.arg(signal.arg())
.arg(pid.to_string())
.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::Other,
format!("kill exited with {status}"),
))
}
}
struct App {
sampler: Sampler,
view: SystemView,
aisling: Aisling,
selected: usize,
selected_pid: Option<u32>,
scroll: usize,
sort: SortMode,
descending: bool,
search: String,
visible: Vec<VisibleProcess>,
visible_dirty: bool,
tree_mode: bool,
anomaly_mode: bool,
effects_enabled: bool,
show_details: bool,
cpu_history: VecDeque<f32>,
mem_history: VecDeque<f32>,
spotlight: bool,
paused: bool,
last_refresh: Instant,
status: String,
signal_palette: Option<u32>,
signal_confirm: Option<SignalAction>,
hovered: Option<usize>,
mouse_pos: Option<(u16, u16)>,
table_hotspot: TableHotspot,
}
impl App {
fn new() -> Self {
Self {
sampler: Sampler::new(),
view: SystemView::default(),
aisling: Aisling::new(),
selected: 0,
selected_pid: None,
scroll: 0,
sort: SortMode::Cpu,
descending: true,
search: String::new(),
visible: Vec::new(),
visible_dirty: true,
tree_mode: false,
anomaly_mode: false,
effects_enabled: true,
show_details: true,
cpu_history: VecDeque::with_capacity(HISTORY_LIMIT),
mem_history: VecDeque::with_capacity(HISTORY_LIMIT),
spotlight: false,
paused: false,
last_refresh: Instant::now() - REFRESH_EVERY,
status: String::from("/ opens the spotlight grep"),
signal_palette: None,
signal_confirm: None,
hovered: None,
mouse_pos: None,
table_hotspot: TableHotspot::default(),
}
}
fn base_style(&self) -> Style {
Style::new(
Color::Rgb {
r: 226,
g: 232,
b: 240,
},
Color::Rgb { r: 2, g: 6, b: 23 },
)
}
fn refresh(&mut self) {
match self.sampler.sample() {
Ok(view) => {
self.view = view;
self.record_history();
self.mark_visible_dirty();
self.rebuild_visible();
self.last_refresh = Instant::now();
}
Err(error) => {
self.status = format!("refresh failed: {error}");
self.last_refresh = Instant::now();
}
}
}
fn record_history(&mut self) {
push_history(&mut self.cpu_history, self.view.cpu_total);
push_history(
&mut self.mem_history,
percent_ratio(self.view.memory.used, self.view.memory.total) * 100.0,
);
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
if self.signal_confirm.is_some() {
return self.handle_signal_confirm_key(key);
}
if self.signal_palette.is_some() {
return self.handle_signal_palette_key(key);
}
if self.spotlight {
return self.handle_spotlight_key(key);
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('/') => self.open_spotlight(),
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.open_spotlight()
}
KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
KeyCode::PageDown => self.move_selection(12),
KeyCode::PageUp => self.move_selection(-12),
KeyCode::Home => self.set_selected_index(0),
KeyCode::End => {
let end = self.visible_count().saturating_sub(1);
self.set_selected_index(end);
}
KeyCode::Tab => self.cycle_sort(),
KeyCode::Char('c') => self.set_sort(SortMode::Cpu),
KeyCode::Char('m') => self.set_sort(SortMode::Mem),
KeyCode::Char('p') => self.set_sort(SortMode::Pid),
KeyCode::Char('n') => self.set_sort(SortMode::Name),
KeyCode::Char('u') => self.set_sort(SortMode::User),
KeyCode::Char('t') => self.set_sort(SortMode::Threads),
KeyCode::Char('a') => self.set_sort(SortMode::Age),
KeyCode::Char('T') => {
self.tree_mode = !self.tree_mode;
if self.tree_mode {
self.anomaly_mode = false;
}
self.mark_visible_dirty();
self.status = if self.tree_mode {
"tree mode enabled"
} else {
"flat mode enabled"
}
.to_string();
}
KeyCode::Char('A') => {
self.anomaly_mode = !self.anomaly_mode;
if self.anomaly_mode {
self.tree_mode = false;
}
self.mark_visible_dirty();
self.status = if self.anomaly_mode {
"anomaly view enabled"
} else {
"anomaly view disabled"
}
.to_string();
}
KeyCode::Char('e') => {
self.effects_enabled = !self.effects_enabled;
self.status = if self.effects_enabled {
"aisling effects enabled"
} else {
"aisling effects disabled"
}
.to_string();
}
KeyCode::Char('r') => {
self.descending = !self.descending;
self.mark_visible_dirty();
}
KeyCode::Char('d') => {
self.show_details = !self.show_details;
self.status = if self.show_details {
"detail pane enabled"
} else {
"detail pane hidden"
}
.to_string();
}
KeyCode::Char(' ') => {
self.paused = !self.paused;
self.status = if self.paused { "paused" } else { "resumed" }.to_string();
}
KeyCode::Char('x') => {
self.search.clear();
self.reset_selection();
self.mark_visible_dirty();
self.status = "filter cleared".to_string();
}
KeyCode::Char('g') => {
self.refresh();
self.status = "forced refresh".to_string();
}
KeyCode::Char('s') => self.open_signal_palette(),
KeyCode::Char('K') => self.prepare_signal(SignalKind::Term),
_ => {}
}
false
}
fn handle_mouse(&mut self, mouse: MouseEvent) {
self.mouse_pos = Some((mouse.column, mouse.row));
match mouse.kind {
MouseEventKind::Moved => {
self.hovered = self.visible_row_at(mouse.column, mouse.row);
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some(row) = self.visible_row_at(mouse.column, mouse.row) {
self.set_selected_index(row);
self.hovered = Some(row);
}
}
MouseEventKind::Down(MouseButton::Right) => {
if let Some(row) = self.visible_row_at(mouse.column, mouse.row) {
self.set_selected_index(row);
self.hovered = Some(row);
self.open_signal_palette();
}
}
MouseEventKind::Down(MouseButton::Middle) => {
if let Some(row) = self.visible_row_at(mouse.column, mouse.row) {
self.set_selected_index(row);
self.prepare_signal(SignalKind::Term);
}
}
MouseEventKind::ScrollDown => self.move_selection(3),
MouseEventKind::ScrollUp => self.move_selection(-3),
_ => {}
}
}
fn visible_row_at(&self, x: u16, y: u16) -> Option<usize> {
self.table_hotspot
.row_at(x, y)
.filter(|row| *row < self.visible.len())
}
fn handle_spotlight_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => self.spotlight = false,
KeyCode::Enter => {
self.spotlight = false;
self.status = format!("{} matching processes", self.visible_count());
}
KeyCode::Backspace => {
self.search.pop();
self.reset_selection();
self.mark_visible_dirty();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.search.clear();
self.reset_selection();
self.mark_visible_dirty();
}
KeyCode::Char(ch) => {
if !key.modifiers.contains(KeyModifiers::CONTROL) {
self.search.push(ch);
self.reset_selection();
self.mark_visible_dirty();
}
}
KeyCode::Down => self.move_selection(1),
KeyCode::Up => self.move_selection(-1),
_ => {}
}
false
}
fn handle_signal_palette_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => {
self.signal_palette = None;
self.status = "signal palette closed".to_string();
}
KeyCode::Char('t') => self.prepare_signal(SignalKind::Term),
KeyCode::Char('k') => self.prepare_signal(SignalKind::Kill),
KeyCode::Char('s') => self.prepare_signal(SignalKind::Stop),
KeyCode::Char('c') => self.prepare_signal(SignalKind::Continue),
KeyCode::Char('i') => self.prepare_signal(SignalKind::Interrupt),
KeyCode::Char('h') => self.prepare_signal(SignalKind::Hangup),
_ => {}
}
false
}
fn handle_signal_confirm_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
if let Some(action) = self.signal_confirm.take() {
self.send_signal(action);
}
}
KeyCode::Char('n') | KeyCode::Esc => {
self.signal_confirm = None;
self.status = "signal cancelled".to_string();
}
_ => {}
}
false
}
fn open_spotlight(&mut self) {
self.spotlight = true;
self.status = "spotlight: type tokens, fields, or thresholds".to_string();
}
fn open_signal_palette(&mut self) {
let Some(pid) = self.current_pid() else {
self.status = "no selected process".to_string();
return;
};
self.signal_palette = Some(pid);
self.status = format!("signal palette for pid {pid}");
}
fn current_pid(&mut self) -> Option<u32> {
self.rebuild_visible();
let Some(entry) = self.visible.get(self.selected) else {
return None;
};
Some(self.view.processes[entry.index].pid)
}
fn prepare_signal(&mut self, signal: SignalKind) {
let Some(pid) = self.signal_palette.or_else(|| self.current_pid()) else {
self.status = "no selected process".to_string();
return;
};
self.signal_palette = None;
self.signal_confirm = Some(SignalAction { pid, signal });
}
fn send_signal(&mut self, action: SignalAction) {
match send_signal_to_pid(action.pid, action.signal) {
Ok(()) => {
self.status = format!("sent {} to pid {}", action.signal.label(), action.pid);
self.refresh();
}
Err(error) => self.status = format!("signal failed: {error}"),
}
}
fn set_sort(&mut self, sort: SortMode) {
if self.sort == sort {
self.descending = !self.descending;
} else {
self.sort = sort;
self.descending = matches!(
sort,
SortMode::Cpu | SortMode::Mem | SortMode::Threads | SortMode::Age
);
}
self.mark_visible_dirty();
}
fn cycle_sort(&mut self) {
let next = match self.sort {
SortMode::Cpu => SortMode::Mem,
SortMode::Mem => SortMode::Pid,
SortMode::Pid => SortMode::Name,
SortMode::Name => SortMode::User,
SortMode::User => SortMode::Threads,
SortMode::Threads => SortMode::Age,
SortMode::Age => SortMode::Cpu,
};
self.set_sort(next);
}
fn move_selection(&mut self, delta: isize) {
let count = self.visible_count();
if count == 0 {
self.reset_selection();
return;
}
let next = (self.selected as isize + delta).clamp(0, count.saturating_sub(1) as isize);
self.set_selected_index(next as usize);
}
fn reset_selection(&mut self) {
self.selected = 0;
self.scroll = 0;
self.selected_pid = None;
}
fn set_selected_index(&mut self, index: usize) {
self.rebuild_visible();
if self.visible.is_empty() {
self.reset_selection();
return;
}
self.selected = index.min(self.visible.len() - 1);
self.selected_pid = self
.visible
.get(self.selected)
.map(|entry| self.view.processes[entry.index].pid);
}
fn clamp_selection_to_visible(&mut self) {
let count = self.visible.len();
if count == 0 {
self.reset_selection();
} else {
if let Some(pid) = self.selected_pid {
if let Some(position) = self
.visible
.iter()
.position(|entry| self.view.processes[entry.index].pid == pid)
{
self.selected = position;
} else {
self.selected = self.selected.min(count - 1);
}
} else {
self.selected = self.selected.min(count - 1);
}
self.selected_pid = self
.visible
.get(self.selected)
.map(|entry| self.view.processes[entry.index].pid);
self.scroll = self.scroll.min(count - 1);
}
}
fn visible_count(&mut self) -> usize {
self.rebuild_visible();
self.visible.len()
}
fn mark_visible_dirty(&mut self) {
self.visible_dirty = true;
}
fn draw_boot_loader(&self, frame: &mut Frame, label: &str) {
frame.clear(self.base_style());
if frame.width() < 32 || frame.height() < 8 {
frame.text(
0,
0,
"ktop loading",
self.base_style().fg(self.aisling.accent()).bold(),
);
return;
}
self.draw_matrix_effect(frame);
let width = frame.width().saturating_sub(10).min(72).max(28);
let rect = Rect {
x: frame.width().saturating_sub(width) / 2,
y: frame.height().saturating_sub(9) / 2,
width,
height: 9,
};
let bg = Color::Rgb { r: 4, g: 10, b: 24 };
let base = self.base_style().bg(bg);
frame.fill_rect(rect, ' ', base);
frame.border(
rect,
base.fg(self.aisling.accent()).bold(),
Some("AISLING BOOT"),
);
frame.text_center(
rect,
rect.y + 2,
"KTOP",
base.fg(self.aisling.accent()).bold(),
);
frame.text_center(rect, rect.y + 3, label, base.fg(Aisling::soft()));
let bar_width = rect.width.saturating_sub(8);
let bar_x = rect.x + 4;
let bar_y = rect.y + 5;
frame.fill_rect(
Rect {
x: bar_x,
y: bar_y,
width: bar_width,
height: 1,
},
'.',
base.fg(Aisling::dim()),
);
let head = self.aisling.tick(18.0, bar_width.max(1) as usize) as u16;
for offset in 0..bar_width.min(16) {
let x = bar_x + (head + offset) % bar_width.max(1);
frame.set(
x,
bar_y,
loader_char(offset as usize),
base.fg(self.aisling.loader(offset as usize)).bold(),
);
}
frame.text_center(
rect,
rect.y + 7,
"diff renderer armed / bounded procfs reads",
base.fg(Aisling::dim()),
);
}
fn rebuild_visible(&mut self) {
if !self.visible_dirty {
return;
}
let query = Query::parse(&self.search);
let sort = self.sort;
let descending = self.descending;
let processes = &self.view.processes;
self.visible = if self.anomaly_mode {
build_anomaly_visible(processes, &query)
} else if self.tree_mode {
build_tree_visible(processes, &query, sort, descending)
} else {
let mut indices = processes
.iter()
.enumerate()
.filter_map(|(index, process)| query.matches(process).then_some(index))
.collect::<Vec<_>>();
sort_process_indices(&mut indices, processes, sort, descending);
indices
.into_iter()
.map(|index| VisibleProcess { index, depth: 0 })
.collect()
};
self.visible_dirty = false;
self.clamp_selection_to_visible();
}
fn draw(&mut self, frame: &mut Frame) {
self.rebuild_visible();
frame.clear(self.base_style());
if frame.width() < 40 || frame.height() < 10 {
frame.text(
0,
0,
"ktop needs at least 40x10",
self.base_style().fg(Aisling::warn()).bold(),
);
return;
}
self.draw_matrix_effect(frame);
self.draw_header(frame);
let table_y = self.draw_meters(frame);
let detail_width = self.detail_width(frame);
let table_width = frame.width().saturating_sub(detail_width);
self.draw_table(frame, table_y, table_width);
if detail_width > 0 {
self.draw_details(frame, table_y, table_width, detail_width);
}
self.draw_footer(frame);
self.draw_hover_overlay(frame);
if self.spotlight {
self.draw_spotlight(frame);
}
if let Some(pid) = self.signal_palette {
self.draw_signal_palette(frame, pid);
}
if let Some(action) = self.signal_confirm {
self.draw_signal_confirm(frame, action);
}
}
fn draw_header(&self, frame: &mut Frame) {
let title = "KTOP";
for (index, ch) in title.chars().enumerate() {
let glitching = self.effects_enabled && self.aisling.phase(11.0, index as f32) > 0.965;
frame.set(
2 + index as u16,
0,
if glitching { glitch_char(index) } else { ch },
self.base_style()
.fg(if glitching {
self.aisling.glitch(index)
} else {
self.aisling.title(index)
})
.bold(),
);
}
frame.text(
7,
0,
"scrin / aisling process deck",
self.base_style().fg(Aisling::soft()),
);
let mode = if self.descending { "desc" } else { "asc" };
let pulse = spinner_char(self.aisling.tick(10.0, 4));
let view_mode = if self.anomaly_mode {
"anomaly"
} else if self.tree_mode {
"tree"
} else {
"flat"
};
let paused = if self.paused { " PAUSED" } else { "" };
let right = format!(
"{} {} {} {} | load {:.2} {:.2} {:.2} | {} procs | {} threads{}",
pulse,
self.sort.label(),
mode,
view_mode,
self.view.load.one,
self.view.load.five,
self.view.load.fifteen,
self.view.processes.len(),
self.view.thread_count,
paused
);
frame.text_right(
0,
0,
frame.width(),
&right,
self.base_style().fg(self.aisling.accent()).bold(),
);
}
fn draw_matrix_effect(&self, frame: &mut Frame) {
if !self.effects_enabled || frame.width() < 80 || frame.height() < 16 {
return;
}
let columns = (frame.width() / 18).clamp(3, 10);
let height = frame.height().saturating_sub(4).max(1);
for index in 0..columns {
let x = 5 + index * 18;
if x >= frame.width().saturating_sub(2) {
continue;
}
let phase = self
.aisling
.phase(0.9 + index as f32 * 0.05, index as f32 * 2.1);
let y = 2 + ((height as f32 * phase) as u16).min(height);
let ch = matrix_char(index as usize + y as usize);
frame.set(
x,
y.min(frame.height().saturating_sub(3)),
ch,
self.base_style()
.fg(self.aisling.matrix(index as usize))
.bold(),
);
}
}
fn detail_width(&self, frame: &Frame) -> u16 {
if self.show_details && frame.width() >= 118 && frame.height() >= 18 {
(frame.width() / 3).clamp(34, 46)
} else {
0
}
}
fn draw_meters(&self, frame: &mut Frame) -> u16 {
let width = frame.width().saturating_sub(4);
let mut y = 2;
let label = format!("{:5.1}% total", self.view.cpu_total);
frame.text(2, y, "CPU", self.base_style().fg(Aisling::soft()).bold());
frame.bar(
Rect {
x: 8,
y,
width: width.saturating_sub(6),
height: 1,
},
self.view.cpu_total / 100.0,
self.base_style()
.bg(Aisling::heat(self.view.cpu_total / 100.0)),
self.base_style()
.bg(Color::Rgb {
r: 15,
g: 23,
b: 42,
})
.fg(Aisling::soft()),
&label,
);
y += 1;
let mem_ratio = percent_ratio(self.view.memory.used, self.view.memory.total);
let mem_label = format!(
"{} / {} ({:4.1}%, {} avail)",
format_bytes(self.view.memory.used),
format_bytes(self.view.memory.total),
mem_ratio * 100.0,
format_bytes(self.view.memory.available)
);
frame.text(2, y, "MEM", self.base_style().fg(Aisling::soft()).bold());
frame.bar(
Rect {
x: 8,
y,
width: width.saturating_sub(6),
height: 1,
},
mem_ratio,
self.base_style().bg(Aisling::memory(mem_ratio)),
self.base_style()
.bg(Color::Rgb {
r: 15,
g: 23,
b: 42,
})
.fg(Aisling::soft()),
&mem_label,
);
y += 1;
if self.view.memory.swap_total > 0 {
let swap_ratio = percent_ratio(self.view.memory.swap_used, self.view.memory.swap_total);
let swap_label = format!(
"{} / {} ({:4.1}%)",
format_bytes(self.view.memory.swap_used),
format_bytes(self.view.memory.swap_total),
swap_ratio * 100.0
);
frame.text(2, y, "SWP", self.base_style().fg(Aisling::soft()).bold());
frame.bar(
Rect {
x: 8,
y,
width: width.saturating_sub(6),
height: 1,
},
swap_ratio,
self.base_style().bg(Aisling::memory(swap_ratio)),
self.base_style()
.bg(Color::Rgb {
r: 15,
g: 23,
b: 42,
})
.fg(Aisling::soft()),
&swap_label,
);
y += 1;
}
self.draw_core_strip(frame, y);
y += 1;
self.draw_state_strip(frame, y);
y += 1;
if frame.height() >= 20 {
self.draw_history_strip(frame, y, "CPU trend", &self.cpu_history, HistoryKind::Cpu);
y += 1;
self.draw_history_strip(
frame,
y,
"MEM trend",
&self.mem_history,
HistoryKind::Memory,
);
y += 1;
}
y + 1
}
fn draw_core_strip(&self, frame: &mut Frame, y: u16) {
frame.text(2, y, "Cores", self.base_style().fg(Aisling::soft()).bold());
let mut x = 8_u16;
let segment = 11_u16;
for cpu in &self.view.cpus {
if x.saturating_add(segment) >= frame.width().saturating_sub(1) {
break;
}
let label = format!("{:>2}", cpu.name);
frame.text_fit(x, y, 2, &label, self.base_style().fg(Aisling::dim()));
frame.bar(
Rect {
x: x + 3,
y,
width: 7,
height: 1,
},
cpu.usage / 100.0,
self.base_style().bg(Aisling::heat(cpu.usage / 100.0)),
self.base_style()
.bg(Color::Rgb {
r: 15,
g: 23,
b: 42,
})
.fg(Aisling::soft()),
"",
);
x += segment;
}
}
fn draw_state_strip(&self, frame: &mut Frame, y: u16) {
let states = self.view.states;
let text = format!(
"R {} S {} D {} T {} Z {} I {} ? {}",
states.running,
states.sleeping,
states.disk_sleep,
states.stopped,
states.zombie,
states.idle,
states.other
);
frame.text(2, y, "States", self.base_style().fg(Aisling::soft()).bold());
frame.text_fit(
12,
y,
frame.width().saturating_sub(14),
&text,
self.base_style().fg(Aisling::soft()),
);
}
fn draw_history_strip(
&self,
frame: &mut Frame,
y: u16,
label: &str,
history: &VecDeque<f32>,
kind: HistoryKind,
) {
let start = 12;
let width = frame.width().saturating_sub(start + 2);
if width == 0 {
return;
}
frame.text(2, y, label, self.base_style().fg(Aisling::soft()).bold());
draw_sparkline(frame, start, y, width, history, kind, self.base_style());
}
fn draw_table(&mut self, frame: &mut Frame, table_y: u16, table_width: u16) {
let footer_rows = 2;
let available_rows = frame
.height()
.saturating_sub(table_y)
.saturating_sub(footer_rows);
if available_rows < 2 || table_width < 24 {
self.table_hotspot = TableHotspot::default();
return;
}
self.rebuild_visible();
let visible_len = self.visible.len();
self.ensure_visible(available_rows.saturating_sub(1) as usize, visible_len);
let header_style = self.base_style().fg(self.aisling.accent()).bold();
frame.text_fit(
1,
table_y,
table_width.saturating_sub(2),
&self.table_header(table_width),
header_style,
);
if visible_len == 0 {
frame.text(
2,
table_y + 2,
"No processes match the spotlight query. Try clearing tokens with Ctrl+U or x.",
self.base_style().fg(Aisling::warn()).bold(),
);
return;
}
let rows = available_rows.saturating_sub(1) as usize;
self.table_hotspot = TableHotspot {
x: 0,
y: table_y + 1,
width: table_width,
rows,
scroll: self.scroll,
};
let end = self.scroll.saturating_add(rows).min(visible_len);
for visible_row in self.scroll..end {
let screen_row = visible_row - self.scroll;
let y = table_y + 1 + screen_row as u16;
let entry = self.visible[visible_row];
let process = &self.view.processes[entry.index];
let selected = self.scroll + screen_row == self.selected;
let hovered = self.hovered == Some(self.scroll + screen_row);
let row_bg = if selected {
self.aisling.selected_bg()
} else if hovered {
Color::Rgb {
r: 17,
g: 31,
b: 49,
}
} else if screen_row % 2 == 0 {
Color::Rgb { r: 3, g: 7, b: 18 }
} else {
Color::Rgb { r: 8, g: 13, b: 28 }
};
frame.fill_rect(
Rect {
x: 0,
y,
width: table_width,
height: 1,
},
' ',
self.base_style().bg(row_bg),
);
self.draw_process_row(
frame,
y,
table_width,
process,
entry.depth,
row_bg,
selected,
);
}
}
fn ensure_visible(&mut self, rows: usize, visible_len: usize) {
if visible_len == 0 || rows == 0 {
self.selected = 0;
self.scroll = 0;
return;
}
self.selected = self.selected.min(visible_len - 1);
if self.selected < self.scroll {
self.scroll = self.selected;
} else if self.selected >= self.scroll + rows {
self.scroll = self.selected.saturating_sub(rows - 1);
}
}
fn table_header(&self, width: u16) -> String {
let name = if self.anomaly_mode {
"ANOMALY/NAME"
} else if self.tree_mode {
"TREE/NAME"
} else {
"NAME"
};
if width >= 110 {
format!(" PID CPU% MEM% RSS VIRT TH AGE S USER {name:<17}COMMAND")
} else if width < 92 {
format!(" PID CPU% MEM% RSS S USER {name:<17}CMD")
} else {
format!(" PID CPU% MEM% RSS VIRT TH S USER {name:<17}COMMAND")
}
}
fn draw_process_row(
&self,
frame: &mut Frame,
y: u16,
table_width: u16,
process: &ProcessInfo,
depth: usize,
bg: Color,
selected: bool,
) {
let base = self.base_style().bg(bg);
let strong = if selected { base.bold() } else { base };
let cpu_style = strong.fg(Aisling::heat((process.cpu / 100.0).min(1.0)));
let mem_style = strong.fg(Aisling::memory((process.mem / 25.0).min(1.0)));
let state_style = strong.fg(state_color(process.state));
let mut x = 1;
put(
frame,
&mut x,
y,
7,
&format!("{:>6}", process.pid),
strong.fg(Aisling::soft()),
);
put(
frame,
&mut x,
y,
7,
&format!("{:>6.1}", process.cpu),
cpu_style.bold(),
);
put(
frame,
&mut x,
y,
7,
&format!("{:>6.1}", process.mem),
mem_style,
);
put(
frame,
&mut x,
y,
9,
&format!("{:>8}", format_bytes(process.rss)),
strong.fg(Aisling::soft()),
);
if table_width >= 92 {
put(
frame,
&mut x,
y,
9,
&format!("{:>8}", format_bytes(process.vms)),
strong.fg(Aisling::dim()),
);
put(
frame,
&mut x,
y,
5,
&format!("{:>4}", process.threads),
strong.fg(Aisling::soft()),
);
}
if table_width >= 110 {
put(
frame,
&mut x,
y,
9,
&format!("{:<8}", format_duration_short(process.runtime)),
strong.fg(Aisling::dim()),
);
}
put(
frame,
&mut x,
y,
3,
&process.state.to_string(),
state_style.bold(),
);
put(
frame,
&mut x,
y,
11,
process.user.as_ref(),
strong.fg(Aisling::soft()),
);
let display_name = if self.anomaly_mode {
anomaly_name(process.name.as_ref(), anomaly_score(process))
} else if self.tree_mode {
tree_name(process.name.as_ref(), depth)
} else {
process.name.as_ref().to_string()
};
put(
frame,
&mut x,
y,
17,
&display_name,
strong
.fg(if selected {
self.aisling.accent()
} else if self.anomaly_mode {
Aisling::burn((anomaly_score(process) / 120.0).min(1.0))
} else {
Aisling::ok()
})
.bold(),
);
let remaining = table_width.saturating_sub(x).saturating_sub(1);
frame.text_fit(
x,
y,
remaining,
process.cmd.as_ref(),
strong.fg(Color::Rgb {
r: 203,
g: 213,
b: 225,
}),
);
}
fn selected_process(&self) -> Option<&ProcessInfo> {
self.visible
.get(self.selected)
.map(|entry| &self.view.processes[entry.index])
}
fn selected_depth(&self) -> usize {
self.visible
.get(self.selected)
.map(|entry| entry.depth)
.unwrap_or(0)
}
fn draw_details(&self, frame: &mut Frame, table_y: u16, x: u16, width: u16) {
let height = frame.height().saturating_sub(table_y).saturating_sub(2);
if width < 30 || height < 8 {
return;
}
let bg = Color::Rgb { r: 5, g: 10, b: 24 };
let rect = Rect {
x,
y: table_y,
width,
height,
};
let base = self.base_style().bg(bg);
frame.fill_rect(rect, ' ', base);
frame.border(
rect,
base.fg(self.aisling.accent()).bold(),
Some("PROCESS DECK"),
);
let inner_x = rect.x + 2;
let inner_width = rect.width.saturating_sub(4);
let mut y = rect.y + 2;
let Some(process) = self.selected_process() else {
frame.text_fit(
inner_x,
y,
inner_width,
"No selected process",
base.fg(Aisling::warn()).bold(),
);
return;
};
let row = format!("row {} / {}", self.selected + 1, self.visible.len());
frame.text_fit(
inner_x,
y,
inner_width,
process.name.as_ref(),
base.fg(self.aisling.accent()).bold(),
);
frame.text_right(inner_x, y, inner_width, &row, base.fg(Aisling::dim()));
y += 2;
frame.bar(
Rect {
x: inner_x,
y,
width: inner_width,
height: 1,
},
process.cpu / 100.0,
base.bg(Aisling::heat((process.cpu / 100.0).min(1.0))),
base.bg(Color::Rgb {
r: 15,
g: 23,
b: 42,
})
.fg(Aisling::soft()),
&format!("CPU {:5.1}%", process.cpu),
);
y += 1;
frame.bar(
Rect {
x: inner_x,
y,
width: inner_width,
height: 1,
},
process.mem / 100.0,
base.bg(Aisling::memory((process.mem / 15.0).min(1.0))),
base.bg(Color::Rgb {
r: 15,
g: 23,
b: 42,
})
.fg(Aisling::soft()),
&format!("MEM {:5.1}%", process.mem),
);
y += 2;
let detail_bottom = rect.bottom().saturating_sub(1);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"PID",
&process.pid.to_string(),
bg,
);
if self.anomaly_mode || is_anomaly(process) {
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"SCORE",
&format!("{:.1}", anomaly_score(process)),
bg,
);
}
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"AGE",
&format_duration(process.runtime),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"BOOT+",
&format_duration(process.started_after_boot),
bg,
);
if self.tree_mode {
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"DEPTH",
&self.selected_depth().to_string(),
bg,
);
}
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"PPID",
&process.ppid.to_string(),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"USER",
process.user.as_ref(),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"UID",
&process.uid.to_string(),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"STATE",
&process.state.to_string(),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"THREADS",
&process.threads.to_string(),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"RSS",
&format_bytes(process.rss),
bg,
);
self.detail_row(
frame,
inner_x,
&mut y,
inner_width,
detail_bottom,
"VIRT",
&format_bytes(process.vms),
bg,
);
if y + 2 < rect.bottom() {
y += 1;
frame.text_fit(
inner_x,
y,
inner_width,
"COMMAND",
base.fg(Aisling::soft()).bold(),
);
y += 1;
draw_wrapped(
frame,
inner_x,
&mut y,
inner_width,
rect.bottom().saturating_sub(1),
process.cmd.as_ref(),
base.fg(Color::Rgb {
r: 203,
g: 213,
b: 225,
}),
);
}
}
fn detail_row(
&self,
frame: &mut Frame,
x: u16,
y: &mut u16,
width: u16,
max_y: u16,
label: &str,
value: &str,
bg: Color,
) {
if width < 12 || *y >= max_y {
return;
}
let base = self.base_style().bg(bg);
frame.text_fit(x, *y, 9, label, base.fg(Aisling::dim()).bold());
frame.text_fit(
x + 10,
*y,
width.saturating_sub(10),
value,
base.fg(Aisling::soft()),
);
*y += 1;
}
fn draw_footer(&self, frame: &mut Frame) {
let y = frame.height().saturating_sub(1);
let visible = self.visible.len();
let filter = if self.search.is_empty() {
"filter: none".to_string()
} else {
format!("filter: {}", self.search)
};
let help = "q quit / grep mouse hover/right-click T tree A anomalies e effects s signals c/m/p/n/u/t/a sort";
frame.text_fit(
1,
y,
frame.width().saturating_sub(2),
help,
self.base_style().fg(Aisling::dim()),
);
frame.text_fit(
1,
y.saturating_sub(1),
frame.width().saturating_sub(2),
&format!("{} | showing {} | {}", self.status, visible, filter),
self.base_style().fg(Aisling::soft()),
);
}
fn draw_hover_overlay(&self, frame: &mut Frame) {
if self.spotlight || self.signal_palette.is_some() || self.signal_confirm.is_some() {
return;
}
let Some(row) = self.hovered else {
return;
};
let Some((mouse_x, mouse_y)) = self.mouse_pos else {
return;
};
let Some(entry) = self.visible.get(row) else {
return;
};
let process = &self.view.processes[entry.index];
let width = 56_u16.min(frame.width().saturating_sub(2)).max(28);
let height = 5_u16;
let mut x = mouse_x.saturating_add(2);
if x.saturating_add(width) >= frame.width() {
x = frame.width().saturating_sub(width + 1);
}
let mut y = mouse_y.saturating_add(1);
if y.saturating_add(height) >= frame.height().saturating_sub(1) {
y = mouse_y.saturating_sub(height + 1);
}
let bg = Color::Rgb { r: 8, g: 18, b: 34 };
let rect = Rect {
x,
y,
width,
height,
};
let base = self.base_style().bg(bg);
frame.fill_rect(rect, ' ', base);
frame.border(
rect,
base.fg(self.aisling.accent()).bold(),
Some("HOVER ACTIONS"),
);
frame.text_fit(
x + 2,
y + 1,
width.saturating_sub(4),
&format!("{} pid {}", process.name.as_ref(), process.pid),
base.fg(Color::White).bold(),
);
frame.text_fit(
x + 2,
y + 2,
width.saturating_sub(4),
"left select right signals middle TERM",
base.fg(Aisling::soft()),
);
frame.text_fit(
x + 2,
y + 3,
width.saturating_sub(4),
&format!(
"cpu {:4.1}% mem {:4.1}% score {:4.0}",
process.cpu,
process.mem,
anomaly_score(process)
),
base.fg(Aisling::warn()),
);
}
fn draw_spotlight(&self, frame: &mut Frame) {
let width = frame.width().saturating_sub(8).min(78).max(32);
let height = 10_u16.min(frame.height().saturating_sub(2)).max(7);
let rect = Rect {
x: frame.width().saturating_sub(width) / 2,
y: frame.height().saturating_sub(height) / 3,
width,
height,
};
frame.dim_outside(rect, Aisling::dim(), Color::Rgb { r: 0, g: 0, b: 0 });
frame.fill_rect(rect, ' ', self.base_style().bg(self.aisling.spotlight_bg()));
frame.border(
rect,
self.base_style()
.fg(self.aisling.accent())
.bg(self.aisling.spotlight_bg())
.bold(),
Some("SPOTLIGHT GREP"),
);
let inner = Rect {
x: rect.x + 2,
y: rect.y + 2,
width: rect.width.saturating_sub(4),
height: rect.height.saturating_sub(4),
};
let query = format!(
"> {}{}",
self.search,
if self.aisling.phase(4.0, 0.0) > 0.5 {
"_"
} else {
" "
}
);
frame.text_fit(
inner.x,
inner.y,
inner.width,
&query,
self.base_style()
.fg(Color::White)
.bg(self.aisling.spotlight_bg())
.bold(),
);
frame.text_fit(
inner.x,
inner.y + 1,
inner.width,
"tokens: chrome user:root cpu>20 mem>5 age>2h state:R !sleep cmd:ssh",
self.base_style()
.fg(Aisling::soft())
.bg(self.aisling.spotlight_bg()),
);
frame.text_fit(
inner.x,
inner.y + 2,
inner.width,
&format!(
"{} live matches. Enter keeps filter, Esc hides overlay, Ctrl+U clears.",
self.visible.len()
),
self.base_style()
.fg(Aisling::ok())
.bg(self.aisling.spotlight_bg()),
);
for (row, entry) in self.visible.iter().take(3).enumerate() {
let process = &self.view.processes[entry.index];
let text = format!(
"{:>6} {:>5.1}% {:>5.1}% {:<14} {}",
process.pid,
process.cpu,
process.mem,
process.name.as_ref(),
process.cmd.as_ref()
);
frame.text_fit(
inner.x,
inner.y + 4 + row as u16,
inner.width,
&text,
self.base_style()
.fg(Color::Rgb {
r: 226,
g: 232,
b: 240,
})
.bg(self.aisling.spotlight_bg()),
);
}
}
fn draw_signal_palette(&self, frame: &mut Frame, pid: u32) {
let width = 64_u16.min(frame.width().saturating_sub(4)).max(36);
let rect = Rect {
x: frame.width().saturating_sub(width) / 2,
y: frame.height().saturating_sub(10) / 2,
width,
height: 10,
};
let bg = Color::Rgb {
r: 12,
g: 20,
b: 36,
};
frame.dim_outside(rect, Aisling::dim(), Color::Rgb { r: 0, g: 0, b: 0 });
frame.fill_rect(rect, ' ', self.base_style().bg(bg));
frame.border(
rect,
self.base_style().fg(self.aisling.accent()).bg(bg).bold(),
Some("SIGNAL PALETTE"),
);
let style = self.base_style().bg(bg);
frame.text_center(
rect,
rect.y + 2,
&format!("Select signal for pid {pid}"),
style.fg(Color::White).bold(),
);
let rows = [
("t", SignalKind::Term),
("k", SignalKind::Kill),
("s", SignalKind::Stop),
("c", SignalKind::Continue),
("i", SignalKind::Interrupt),
("h", SignalKind::Hangup),
];
for (row, (key, signal)) in rows.iter().enumerate() {
let text = format!("{} {:<5} {}", key, signal.label(), signal.description());
frame.text_fit(
rect.x + 4,
rect.y + 4 + row as u16,
rect.width.saturating_sub(8),
&text,
style.fg(if matches!(*signal, SignalKind::Kill) {
Aisling::danger()
} else {
Aisling::soft()
}),
);
}
}
fn draw_signal_confirm(&self, frame: &mut Frame, action: SignalAction) {
let width = 54_u16.min(frame.width().saturating_sub(4)).max(28);
let rect = Rect {
x: frame.width().saturating_sub(width) / 2,
y: frame.height().saturating_sub(7) / 2,
width,
height: 7,
};
frame.dim_outside(rect, Aisling::dim(), Color::Rgb { r: 0, g: 0, b: 0 });
frame.fill_rect(
rect,
' ',
self.base_style().bg(Color::Rgb {
r: 45,
g: 15,
b: 24,
}),
);
frame.border(
rect,
self.base_style()
.fg(Aisling::danger())
.bg(Color::Rgb {
r: 45,
g: 15,
b: 24,
})
.bold(),
Some("CONFIRM SIGNAL"),
);
frame.text_center(
rect,
rect.y + 2,
&format!("Send {} to pid {}?", action.signal.label(), action.pid),
self.base_style()
.fg(Color::White)
.bg(Color::Rgb {
r: 45,
g: 15,
b: 24,
})
.bold(),
);
frame.text_center(
rect,
rect.y + 4,
"y/Enter confirms, n/Esc cancels",
self.base_style().fg(Aisling::soft()).bg(Color::Rgb {
r: 45,
g: 15,
b: 24,
}),
);
}
}
#[derive(Clone, Copy)]
struct VisibleProcess {
index: usize,
depth: usize,
}
#[derive(Clone, Copy, Default)]
struct TableHotspot {
x: u16,
y: u16,
width: u16,
rows: usize,
scroll: usize,
}
impl TableHotspot {
fn row_at(self, x: u16, y: u16) -> Option<usize> {
if x < self.x || x >= self.x.saturating_add(self.width) || y < self.y {
return None;
}
let row = y.saturating_sub(self.y) as usize;
(row < self.rows).then_some(self.scroll + row)
}
}
#[derive(Clone, Copy)]
struct SignalAction {
pid: u32,
signal: SignalKind,
}
#[derive(Clone, Copy)]
enum SignalKind {
Term,
Kill,
Stop,
Continue,
Interrupt,
Hangup,
}
impl SignalKind {
fn arg(self) -> &'static str {
match self {
SignalKind::Term => "-TERM",
SignalKind::Kill => "-KILL",
SignalKind::Stop => "-STOP",
SignalKind::Continue => "-CONT",
SignalKind::Interrupt => "-INT",
SignalKind::Hangup => "-HUP",
}
}
fn label(self) -> &'static str {
match self {
SignalKind::Term => "TERM",
SignalKind::Kill => "KILL",
SignalKind::Stop => "STOP",
SignalKind::Continue => "CONT",
SignalKind::Interrupt => "INT",
SignalKind::Hangup => "HUP",
}
}
fn description(self) -> &'static str {
match self {
SignalKind::Term => "request graceful shutdown",
SignalKind::Kill => "force immediate kill",
SignalKind::Stop => "pause execution",
SignalKind::Continue => "resume stopped process",
SignalKind::Interrupt => "send terminal interrupt",
SignalKind::Hangup => "reload/hangup where supported",
}
}
fn parse(input: &str) -> Option<Self> {
match input.trim().to_ascii_uppercase().as_str() {
"TERM" | "SIGTERM" | "15" => Some(Self::Term),
"KILL" | "SIGKILL" | "9" => Some(Self::Kill),
"STOP" | "SIGSTOP" | "19" => Some(Self::Stop),
"CONT" | "SIGCONT" | "18" => Some(Self::Continue),
"INT" | "SIGINT" | "2" => Some(Self::Interrupt),
"HUP" | "SIGHUP" | "1" => Some(Self::Hangup),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SortMode {
Cpu,
Mem,
Pid,
Name,
User,
Threads,
Age,
}
impl SortMode {
fn label(self) -> &'static str {
match self {
SortMode::Cpu => "cpu",
SortMode::Mem => "mem",
SortMode::Pid => "pid",
SortMode::Name => "name",
SortMode::User => "user",
SortMode::Threads => "threads",
SortMode::Age => "age",
}
}
}
#[derive(Clone, Copy)]
enum HistoryKind {
Cpu,
Memory,
}
fn compare_processes(left: &ProcessInfo, right: &ProcessInfo, sort: SortMode) -> Ordering {
let ordering = match sort {
SortMode::Cpu => left.cpu.partial_cmp(&right.cpu).unwrap_or(Ordering::Equal),
SortMode::Mem => left.mem.partial_cmp(&right.mem).unwrap_or(Ordering::Equal),
SortMode::Pid => left.pid.cmp(&right.pid),
SortMode::Name => left.name.as_ref().cmp(right.name.as_ref()),
SortMode::User => left.user.as_ref().cmp(right.user.as_ref()),
SortMode::Threads => left.threads.cmp(&right.threads),
SortMode::Age => left.runtime.cmp(&right.runtime),
};
ordering.then_with(|| left.pid.cmp(&right.pid))
}
fn sort_process_indices(
indices: &mut [usize],
processes: &[ProcessInfo],
sort: SortMode,
descending: bool,
) {
indices.sort_by(|left, right| {
let ordering = compare_processes(&processes[*left], &processes[*right], sort);
if descending {
ordering.reverse()
} else {
ordering
}
});
}
fn build_tree_visible(
processes: &[ProcessInfo],
query: &Query,
sort: SortMode,
descending: bool,
) -> Vec<VisibleProcess> {
let mut pid_to_index = HashMap::with_capacity(processes.len());
for (index, process) in processes.iter().enumerate() {
pid_to_index.insert(process.pid, index);
}
let mut children: HashMap<u32, Vec<usize>> = HashMap::with_capacity(processes.len());
let mut roots = Vec::new();
for (index, process) in processes.iter().enumerate() {
if process.ppid != 0 && pid_to_index.contains_key(&process.ppid) {
children.entry(process.ppid).or_default().push(index);
} else {
roots.push(index);
}
}
sort_process_indices(&mut roots, processes, sort, descending);
for children in children.values_mut() {
sort_process_indices(children, processes, sort, descending);
}
let mut visible = Vec::with_capacity(processes.len());
let mut memo = HashMap::with_capacity(processes.len());
let mut path = HashSet::new();
for root in roots {
append_tree_visible(
root,
0,
processes,
&children,
query,
&mut memo,
&mut path,
&mut visible,
);
}
visible
}
fn build_anomaly_visible(processes: &[ProcessInfo], query: &Query) -> Vec<VisibleProcess> {
let mut entries = processes
.iter()
.enumerate()
.filter(|(_, process)| query.matches(process) && is_anomaly(process))
.map(|(index, _)| index)
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
anomaly_score(&processes[*right])
.partial_cmp(&anomaly_score(&processes[*left]))
.unwrap_or(Ordering::Equal)
.then_with(|| processes[*left].pid.cmp(&processes[*right].pid))
});
entries
.into_iter()
.map(|index| VisibleProcess { index, depth: 0 })
.collect()
}
fn is_anomaly(process: &ProcessInfo) -> bool {
process.cpu >= 25.0
|| process.mem >= 5.0
|| matches!(process.state, 'D' | 'Z')
|| process.threads >= 128
|| (process.runtime < 60 && process.cpu >= 10.0)
}
fn anomaly_score(process: &ProcessInfo) -> f32 {
let state = match process.state {
'D' => 35.0,
'Z' => 25.0,
'R' => 5.0,
_ => 0.0,
};
let thread_pressure = if process.threads > 64 {
((process.threads - 64) as f32 / 8.0).min(30.0)
} else {
0.0
};
let young_hot = if process.runtime < 60 {
process.cpu * 0.5
} else {
0.0
};
process.cpu + process.mem * 6.0 + state + thread_pressure + young_hot
}
fn append_tree_visible(
index: usize,
depth: usize,
processes: &[ProcessInfo],
children: &HashMap<u32, Vec<usize>>,
query: &Query,
memo: &mut HashMap<usize, bool>,
path: &mut HashSet<usize>,
visible: &mut Vec<VisibleProcess>,
) {
if !query.is_empty() && !subtree_matches(index, processes, children, query, memo, path) {
return;
}
if !path.insert(index) {
return;
}
visible.push(VisibleProcess { index, depth });
if let Some(child_indices) = children.get(&processes[index].pid) {
for child in child_indices {
append_tree_visible(
*child,
depth + 1,
processes,
children,
query,
memo,
path,
visible,
);
}
}
path.remove(&index);
}
fn subtree_matches(
index: usize,
processes: &[ProcessInfo],
children: &HashMap<u32, Vec<usize>>,
query: &Query,
memo: &mut HashMap<usize, bool>,
path: &mut HashSet<usize>,
) -> bool {
if let Some(matched) = memo.get(&index) {
return *matched;
}
if !path.insert(index) {
return false;
}
let mut matched = query.matches(&processes[index]);
if !matched {
if let Some(child_indices) = children.get(&processes[index].pid) {
matched = child_indices
.iter()
.any(|child| subtree_matches(*child, processes, children, query, memo, path));
}
}
path.remove(&index);
memo.insert(index, matched);
matched
}
struct Query {
tokens: Vec<QueryToken>,
}
struct QueryToken {
negated: bool,
kind: QueryKind,
}
enum QueryKind {
Plain(String),
Pid(String),
Ppid(String),
Uid(String),
User(String),
Name(String),
Cmd(String),
State(char),
CpuGt(f32),
CpuLt(f32),
MemGt(f32),
MemLt(f32),
AgeGt(u64),
AgeLt(u64),
}
impl Query {
fn parse(input: &str) -> Self {
let tokens = input
.split_whitespace()
.filter_map(|raw| {
let (negated, token) = raw
.strip_prefix('!')
.map_or((false, raw), |token| (true, token));
if token.is_empty() {
return None;
}
Some(QueryToken {
negated,
kind: QueryKind::parse(token),
})
})
.collect();
Self { tokens }
}
fn matches(&self, process: &ProcessInfo) -> bool {
self.tokens.iter().all(|token| {
let matched = token.kind.matches(process);
matched != token.negated
})
}
fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
}
impl QueryKind {
fn parse(token: &str) -> Self {
if let Some(value) = token.strip_prefix("pid:") {
return Self::Pid(value.to_string());
}
if let Some(value) = token.strip_prefix("ppid:") {
return Self::Ppid(value.to_string());
}
if let Some(value) = token.strip_prefix("uid:") {
return Self::Uid(value.to_string());
}
if let Some(value) = token.strip_prefix("user:") {
return Self::User(value.to_ascii_lowercase());
}
if let Some(value) = token.strip_prefix("name:") {
return Self::Name(value.to_ascii_lowercase());
}
if let Some(value) = token.strip_prefix("cmd:") {
return Self::Cmd(value.to_ascii_lowercase());
}
if let Some(value) = token.strip_prefix("state:") {
return Self::State(value.chars().next().unwrap_or('?').to_ascii_uppercase());
}
if let Some(value) = token.strip_prefix("cpu>") {
return Self::CpuGt(value.parse::<f32>().unwrap_or(f32::MAX));
}
if let Some(value) = token.strip_prefix("cpu<") {
return Self::CpuLt(value.parse::<f32>().unwrap_or(f32::MIN));
}
if let Some(value) = token.strip_prefix("mem>") {
return Self::MemGt(value.parse::<f32>().unwrap_or(f32::MAX));
}
if let Some(value) = token.strip_prefix("mem<") {
return Self::MemLt(value.parse::<f32>().unwrap_or(f32::MIN));
}
if let Some(value) = token.strip_prefix("age>") {
return Self::AgeGt(parse_duration_filter(value).unwrap_or(u64::MAX));
}
if let Some(value) = token.strip_prefix("age<") {
return Self::AgeLt(parse_duration_filter(value).unwrap_or(0));
}
Self::Plain(token.to_ascii_lowercase())
}
fn matches(&self, process: &ProcessInfo) -> bool {
match self {
Self::Plain(value) => process.search_text.as_ref().contains(value),
Self::Pid(value) => process.pid.to_string().contains(value),
Self::Ppid(value) => process.ppid.to_string().contains(value),
Self::Uid(value) => process.uid.to_string().contains(value),
Self::User(value) => process.user_lower.as_ref().contains(value),
Self::Name(value) => process.name_lower.as_ref().contains(value),
Self::Cmd(value) => process.cmd_lower.as_ref().contains(value),
Self::State(value) => process.state.to_ascii_uppercase() == *value,
Self::CpuGt(value) => process.cpu > *value,
Self::CpuLt(value) => process.cpu < *value,
Self::MemGt(value) => process.mem > *value,
Self::MemLt(value) => process.mem < *value,
Self::AgeGt(value) => process.runtime > *value,
Self::AgeLt(value) => process.runtime < *value,
}
}
}
fn state_color(state: char) -> Color {
match state {
'R' => Aisling::ok(),
'D' | 'Z' => Aisling::danger(),
'T' | 't' => Aisling::warn(),
'S' => Color::Rgb {
r: 96,
g: 165,
b: 250,
},
'I' => Aisling::dim(),
_ => Aisling::soft(),
}
}
fn push_history(history: &mut VecDeque<f32>, value: f32) {
if history.len() == HISTORY_LIMIT {
history.pop_front();
}
history.push_back(value.clamp(0.0, 100.0));
}
fn draw_sparkline(
frame: &mut Frame,
x: u16,
y: u16,
width: u16,
history: &VecDeque<f32>,
kind: HistoryKind,
base: Style,
) {
if history.is_empty() {
frame.text_fit(x, y, width, "collecting...", base.fg(Aisling::dim()));
return;
}
let skip = history.len().saturating_sub(width as usize);
for (offset, value) in history.iter().skip(skip).take(width as usize).enumerate() {
let ratio = value / 100.0;
let ch = trend_char(ratio);
let color = match kind {
HistoryKind::Cpu => Aisling::heat(ratio),
HistoryKind::Memory => Aisling::memory(ratio),
};
frame.set(x + offset as u16, y, ch, base.fg(color).bold());
}
}
fn trend_char(ratio: f32) -> char {
const LEVELS: &[u8] = b" .:-=+*#%@";
let index = ((LEVELS.len() - 1) as f32 * ratio.clamp(0.0, 1.0)).round() as usize;
LEVELS[index] as char
}
fn matrix_char(index: usize) -> char {
const GLYPHS: &[u8] = b"0123456789ABCDEF";
GLYPHS[index % GLYPHS.len()] as char
}
fn loader_char(index: usize) -> char {
const GLYPHS: &[u8] = b"=+*#%@";
GLYPHS[index % GLYPHS.len()] as char
}
fn spinner_char(index: usize) -> char {
const GLYPHS: &[u8] = b"|/-\\";
GLYPHS[index % GLYPHS.len()] as char
}
fn glitch_char(index: usize) -> char {
const GLYPHS: &[u8] = b"K70P";
GLYPHS[index % GLYPHS.len()] as char
}
fn tree_name(name: &str, depth: usize) -> String {
if depth == 0 {
return name.to_string();
}
let indent = " ".repeat(depth.min(7));
format!("{indent}|- {name}")
}
fn anomaly_name(name: &str, score: f32) -> String {
format!("!{score:>4.0} {name}")
}
fn draw_wrapped(
frame: &mut Frame,
x: u16,
y: &mut u16,
width: u16,
max_y: u16,
text: &str,
style: Style,
) {
if width == 0 {
return;
}
let mut line = String::new();
for ch in text.chars() {
if *y >= max_y {
return;
}
if line.chars().count() >= width as usize {
frame.text_fit(x, *y, width, &line, style);
*y += 1;
line.clear();
}
line.push(ch);
}
if !line.is_empty() && *y < max_y {
frame.text_fit(x, *y, width, &line, style);
*y += 1;
}
}
fn put(frame: &mut Frame, x: &mut u16, y: u16, width: u16, text: &str, style: Style) {
frame.text_fit(*x, y, width, text, style);
*x = (*x).saturating_add(width);
}
fn percent_ratio(used: u64, total: u64) -> f32 {
if total == 0 {
0.0
} else {
used as f32 / total as f32
}
}
fn format_duration(seconds: u64) -> String {
let days = seconds / 86_400;
let hours = (seconds % 86_400) / 3_600;
let minutes = (seconds % 3_600) / 60;
let secs = seconds % 60;
if days > 0 {
format!("{days}d {hours:02}h {minutes:02}m")
} else if hours > 0 {
format!("{hours}h {minutes:02}m {secs:02}s")
} else if minutes > 0 {
format!("{minutes}m {secs:02}s")
} else {
format!("{secs}s")
}
}
fn format_duration_short(seconds: u64) -> String {
let days = seconds / 86_400;
let hours = (seconds % 86_400) / 3_600;
let minutes = (seconds % 3_600) / 60;
if days > 0 {
format!("{days}d{hours:02}h")
} else if hours > 0 {
format!("{hours}h{minutes:02}m")
} else if minutes > 0 {
format!("{minutes}m")
} else {
format!("{seconds}s")
}
}
fn parse_duration_filter(input: &str) -> Option<u64> {
let mut digits = String::new();
let mut suffix = String::new();
for ch in input.chars() {
if ch.is_ascii_digit() {
if !suffix.is_empty() {
return None;
}
digits.push(ch);
} else {
suffix.push(ch.to_ascii_lowercase());
}
}
let value = digits.parse::<u64>().ok()?;
Some(match suffix.as_str() {
"" | "s" | "sec" | "secs" => value,
"m" | "min" | "mins" => value.saturating_mul(60),
"h" | "hr" | "hrs" => value.saturating_mul(3_600),
"d" | "day" | "days" => value.saturating_mul(86_400),
_ => return None,
})
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{}{}", value as u64, UNITS[unit])
} else if value < 10.0 {
format!("{value:.1}{}", UNITS[unit])
} else {
format!("{value:.0}{}", UNITS[unit])
}
}