Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ on:

jobs:
check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
Expand Down
11 changes: 6 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/target
/target/
**/*.rs.bk
*.swp
*.swo
*~
.DS_Store
.agent/
.claude/worktrees/
demo.tape
.idea/
Thumbs.db
695 changes: 674 additions & 21 deletions LICENSE

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# LLMtop

[![CI](https://github.com/weby-homelab/LLMtop/actions/workflows/ci.yml/badge.svg)](https://github.com/weby-homelab/LLMtop/actions/workflows/ci.yml)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.88%2B-orange.svg)](https://www.rust-lang.org/)

**Like [btop](https://github.com/aristocratos/btop), but for your local LLMs, runners, and AI coding agents.**

See every active local LLM session, model usage, context window, rate limits, child processes, open ports, and more at a glance. Supports Ollama, llama.cpp, vLLM, OpenCode, Odysseus, and any OpenAI-compatible server (LM Studio, LiteLLM, Open WebUI, KoboldCpp, TabbyAPI, etc.).
Expand Down
58 changes: 43 additions & 15 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::collector::{read_rate_limits, McpServer, MultiCollector};
use crate::collector::rate_limit::read_opencode_rate_limits;
use crate::host_info::{AgentAggregate, HostMetrics, HostSampler};
use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus};
use crate::theme::Theme;
Expand Down Expand Up @@ -68,6 +69,7 @@ pub enum NarrowSection {
Sessions,
Projects,
Context,
Quota,
Tokens,
Ports,
Mcp,
Expand All @@ -77,7 +79,7 @@ impl NarrowSection {
pub fn tab(self) -> NarrowTab {
match self {
Self::Sessions | Self::Projects => NarrowTab::Work,
Self::Context | Self::Tokens => NarrowTab::Usage,
Self::Context | Self::Quota | Self::Tokens => NarrowTab::Usage,
Self::Ports | Self::Mcp => NarrowTab::System,
}
}
Expand Down Expand Up @@ -114,6 +116,7 @@ pub struct App {
kill_confirm: Option<(usize, Instant)>,
pub theme: Theme,
pub show_context: bool,
pub show_quota: bool,
pub show_tokens: bool,
pub show_projects: bool,
pub show_ports: bool,
Expand Down Expand Up @@ -189,6 +192,7 @@ impl App {
kill_confirm: None,
theme,
show_context: panels.context,
show_quota: panels.quota,
show_tokens: panels.tokens,
show_projects: panels.projects,
show_ports: panels.ports,
Expand Down Expand Up @@ -232,11 +236,12 @@ impl App {
pub fn toggle_panel(&mut self, panel: u8) {
match panel {
1 => self.show_context = !self.show_context,
2 => self.show_tokens = !self.show_tokens,
3 => self.show_projects = !self.show_projects,
4 => self.show_ports = !self.show_ports,
5 => self.show_sessions = !self.show_sessions,
6 => self.show_mcp = !self.show_mcp,
2 => self.show_quota = !self.show_quota,
3 => self.show_tokens = !self.show_tokens,
4 => self.show_projects = !self.show_projects,
5 => self.show_ports = !self.show_ports,
6 => self.show_sessions = !self.show_sessions,
7 => self.show_mcp = !self.show_mcp,
_ => return,
}
self.persist_panel_visibility();
Expand All @@ -260,6 +265,7 @@ impl App {
fn persist_panel_visibility(&mut self) {
let panels = crate::config::PanelVisibility {
context: self.show_context,
quota: self.show_quota,
tokens: self.show_tokens,
projects: self.show_projects,
ports: self.show_ports,
Expand Down Expand Up @@ -303,11 +309,12 @@ impl App {
return;
}
1 => self.show_context = !self.show_context,
2 => self.show_tokens = !self.show_tokens,
3 => self.show_projects = !self.show_projects,
4 => self.show_ports = !self.show_ports,
5 => self.show_sessions = !self.show_sessions,
6 => self.show_mcp = !self.show_mcp,
2 => self.show_quota = !self.show_quota,
3 => self.show_tokens = !self.show_tokens,
4 => self.show_projects = !self.show_projects,
5 => self.show_ports = !self.show_ports,
6 => self.show_sessions = !self.show_sessions,
7 => self.show_mcp = !self.show_mcp,
_ => return,
}
self.persist_panel_visibility();
Expand All @@ -317,7 +324,7 @@ impl App {
pub fn narrow_tab_visible(&self, tab: NarrowTab) -> bool {
match tab {
NarrowTab::Work => self.show_sessions || self.show_projects,
NarrowTab::Usage => self.show_context || self.show_tokens,
NarrowTab::Usage => self.show_context || self.show_quota || self.show_tokens,
NarrowTab::System => self.show_ports || self.show_mcp,
}
}
Expand Down Expand Up @@ -380,6 +387,7 @@ impl App {
NarrowSection::Sessions => self.show_sessions,
NarrowSection::Projects => self.show_projects,
NarrowSection::Context => self.show_context,
NarrowSection::Quota => self.show_quota,
NarrowSection::Tokens => self.show_tokens,
NarrowSection::Ports => self.show_ports,
NarrowSection::Mcp => self.show_mcp,
Expand All @@ -391,6 +399,7 @@ impl App {
NarrowTab::Work => &[NarrowSection::Sessions, NarrowSection::Projects],
NarrowTab::Usage => &[
NarrowSection::Context,
NarrowSection::Quota,
NarrowSection::Tokens,
],
NarrowTab::System => &[NarrowSection::Ports, NarrowSection::Mcp],
Expand Down Expand Up @@ -532,8 +541,8 @@ impl App {
self.rate_limit_counter = 0;
let extra_dirs = self.collector.all_config_dirs();
self.rate_limits = read_rate_limits(&extra_dirs);
// Merge live rate limits from agent collectors (e.g. Codex JSONL parsing)
self.rate_limits.extend(self.collector.agent_rate_limits());
self.rate_limits.extend(read_opencode_rate_limits());
} else {
self.rate_limit_counter += 1;
}
Expand Down Expand Up @@ -727,8 +736,18 @@ impl App {
return;
}
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.args(["-15", &pid.to_string()])
.output();
std::thread::sleep(std::time::Duration::from_secs(3));
if std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|s| s.success())
{
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
}
Comment on lines 797 to +809

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Blocking the main thread with std::thread::sleep(std::time::Duration::from_secs(3)) in a TUI application will freeze the entire user interface for 3 seconds whenever a session is killed.

To keep the TUI responsive, the graceful kill sequence (sending SIGTERM, waiting, and then sending SIGKILL if still alive) should be executed in a background thread.

Suggested change
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.args(["-15", &pid.to_string()])
.output();
std::thread::sleep(std::time::Duration::from_secs(3));
if std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|s| s.success())
{
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
}
std::thread::spawn(move || {
let _ = std::process::Command::new("kill")
.args(["-15", &pid.to_string()])
.output();
std::thread::sleep(std::time::Duration::from_secs(3));
if std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.is_ok_and(|s| s.success())
{
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
}
});

self.tick();
return;
}
Expand Down Expand Up @@ -955,7 +974,16 @@ fn load_summary_cache() -> HashMap<String, String> {
fn save_summary_cache(summaries: &HashMap<String, String>) {
let path = cache_path();
let _ = std::fs::create_dir_all(cache_dir());
if let Ok(json) = serde_json::to_string(summaries) {
let mut bounded = summaries.clone();
const MAX_CACHED_SUMMARIES: usize = 500;
while bounded.len() > MAX_CACHED_SUMMARIES {
if let Some(oldest_key) = bounded.keys().next().cloned() {
bounded.remove(&oldest_key);
} else {
break;
}
}
Comment on lines +1038 to +1044

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling bounded.keys().next() repeatedly in a loop to evict elements is highly inefficient. Each call to .keys() creates a new iterator that must scan the internal bucket array of the HashMap from the beginning to find the first occupied bucket. If many elements need to be evicted, this results in $O(K \times B)$ complexity (where $K$ is the number of evicted elements and $B$ is the bucket count), which can block the main TUI thread and cause noticeable stuttering.

Additionally, HashMap has no defined order, so keys().next() does not return the "oldest" key but rather an arbitrary one.

We can optimize this to $O(B)$ by collecting the keys to remove in a single pass.

    if bounded.len() > MAX_CACHED_SUMMARIES {
        let keys_to_remove: Vec<String> = bounded.keys()
            .take(bounded.len() - MAX_CACHED_SUMMARIES)
            .cloned()
            .collect();
        for key in keys_to_remove {
            bounded.remove(&key);
        }
    }

if let Ok(json) = serde_json::to_string(&bounded) {
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
Expand Down
2 changes: 1 addition & 1 deletion src/collector/auto_discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ impl AutoDiscoverCollector {
started_at: now_ms,
status,
model: model_str,
effort: format!(":{}", server.port),
effort: String::new(),
context_percent: 0.0,
total_input_tokens: 0,
total_output_tokens: 0,
Expand Down
92 changes: 2 additions & 90 deletions src/collector/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ use super::process::{self, ProcInfo};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
use std::process::{Command, Stdio};
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
use std::time::Instant;
use std::time::{Duration, SystemTime};
use std::process::Command;
use std::time::SystemTime;

/// Active-thread mtime threshold: a rollout written within the last 30 minutes
/// ACTIVE_MTIME_SECS counts as "active". File-descriptor presence alone
Expand Down Expand Up @@ -272,92 +270,6 @@ pub(crate) fn map_pid_to_rollouts(pids: &[u32]) -> HashMap<u32, Vec<PathBuf>> {
map
}

#[allow(dead_code)]
pub(crate) fn map_pid_to_rollouts_with_timeout_and_pid_slot(
pids: &[u32],
timeout: Duration,
child_pid_slot: Option<std::sync::Arc<std::sync::atomic::AtomicU32>>,
) -> Option<HashMap<u32, Vec<PathBuf>>> {
if pids.is_empty() {
return Some(HashMap::new());
}

#[cfg(any(target_os = "linux", target_os = "windows"))]
{
let _ = (timeout, child_pid_slot);
Some(map_pid_to_rollouts(pids))
}

#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
{
let pid_args: Vec<String> = pids.iter().map(|p| format!("-p{}", p)).collect();
let mut args = vec!["-F", "pn"];
for pa in &pid_args {
args.push(pa);
}

let output_file = tempfile::NamedTempFile::new().ok()?;
let output_for_child = output_file.reopen().ok()?;
let mut child = match Command::new("lsof")
.args(&args)
.stdout(Stdio::from(output_for_child))
.stderr(Stdio::null())
.spawn()
{
Ok(child) => child,
Err(_) => return None,
};
let child_pid = child.id();
if let Some(slot) = &child_pid_slot {
slot.store(child_pid, std::sync::atomic::Ordering::SeqCst);
}

let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
if let Some(slot) = &child_pid_slot {
slot.store(0, std::sync::atomic::Ordering::SeqCst);
}
let stdout = std::fs::read_to_string(output_file.path()).ok();
return stdout.map(|s| parse_lsof_rollout_output(&s));
}
Ok(None) if started.elapsed() >= timeout => {
if let Some(slot) = &child_pid_slot {
slot.store(0, std::sync::atomic::Ordering::SeqCst);
}
let _ = Command::new("kill")
.args(["-9", &child_pid.to_string()])
.status();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(100)),
Err(_) => {
if let Some(slot) = &child_pid_slot {
slot.store(0, std::sync::atomic::Ordering::SeqCst);
}
return None;
}
}
}
}
}

#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
fn kill_pid(pid: u32) {
if pid != 0 {
let _ = Command::new("kill").args(["-9", &pid.to_string()]).status();
}
}

#[cfg(any(target_os = "linux", target_os = "windows"))]
fn kill_pid(_pid: u32) {}

pub(crate) fn kill_rollout_scan_child(pid: u32) {
kill_pid(pid);
}

#[cfg(any(
test,
all(not(target_os = "linux"), not(target_os = "windows"))
Expand Down
Loading
Loading