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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ src/
│ token rate (200pt history) S2 prediction █████████91%⚠ │
│ S3 api-server ███ 22% │
└──────────────────────────────────────────────────────────────────────┘
┌─ ²quota ─────┐┌─ ³tokens ───┐┌─ projects ──┐┌─ ports ──────────┐
┌─ ²quota ─────┐┌─ ³tokens ───┐┌─ projects ──┐┌─ ports ──────────┐
│ CLAUDE ││ Total 1.2M ││ abtop ││ PORT SESSION CMD │
│ 5h ████ 35% ││ Input 402k ││ main +3 ~18 ││ :3000 api-srv node│
│ resets 2h ││ Output 89k ││ ││ :8080 predict crgo│
Expand All @@ -44,7 +44,7 @@ src/
│ 5h █ 9% ││ Avg: 25k/t ││ api-server ││ │
│ 7d ██ 14% ││ ││ main ✓clean ││ │
└──────────────┘└─────────────┘└──────────────┘└────────────────────┘
┌─ sessions ─────────────────────────────────────────────────────────┐
┌─ sessions ─────────────────────────────────────────────────────────┐
│ ►*CC 7336 abtop ● Work opus 82% 1.2M 48 Edit src/pay.rs │
│ >CD 8840 pred ◌ Wait sonn 91% 340k 12 waiting │
│ ─────────────────────────────────────────────────────────────────── │
Expand All @@ -67,9 +67,9 @@ Panel descriptions:
- **¹context**: Left = token rate braille sparkline (200-point history). Right = per-session context % bars with yellow/red warning.
- **²quota**: Claude + Codex rate limit gauges side-by-side (5h and 7d windows with reset countdown).
- **³tokens**: Total token breakdown (in/out/cache) + per-turn sparkline for selected session.
- **projects**: Per-project git branch + added/modified file counts.
- **ports**: Agent-spawned open ports + orphan ports (from dead sessions). Conflict detection.
- **sessions**: Full-width panel below mid row. Session list table (top) + selected session detail (bottom), separated by divider.
- **projects** (always visible): Per-project git branch + added/modified file counts.
- **ports**: Agent-spawned open ports + orphan ports (from dead sessions). Conflict detection.
- **sessions**: Full-width panel below mid row. Session list table (top) + selected session detail (bottom), separated by divider.

## Data Sources

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Theme support contributed by [@tbouquet](https://github.com/tbouquet).
| `x` | Kill selected session |
| `X` | Kill all orphan ports |
| `t` | Cycle theme |
| `1`–`5` | Toggle panel visibility |
| `Esc` | Open/close config page |
| `q` | Quit |
| `r` | Force refresh |

Expand Down
Binary file added assets/config-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 59 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,18 @@ pub struct App {
/// Kill confirmation: (selected_index, timestamp). Expires after 2s.
kill_confirm: Option<(usize, Instant)>,
pub theme: Theme,
pub show_context: bool,
pub show_quota: bool,
pub show_tokens: bool,
pub show_ports: bool,
pub show_sessions: bool,
pub config_open: bool,
pub config_selected: usize,
}

impl App {
pub fn new(theme: Theme) -> Self {
let (tx, rx) = mpsc::channel();
// Load cached summaries from disk
let summaries = load_summary_cache();
Self {
sessions: Vec::new(),
Expand All @@ -69,7 +75,7 @@ impl App {
token_rates: VecDeque::with_capacity(GRAPH_HISTORY_LEN),
rate_limits: Vec::new(),
prev_tokens: HashMap::new(),
rate_limit_counter: 5, // trigger on first tick
rate_limit_counter: 5,
collector: MultiCollector::new(),
summaries,
pending_summaries: HashSet::new(),
Expand All @@ -80,6 +86,57 @@ impl App {
status_msg: None,
kill_confirm: None,
theme,
show_context: true,
show_quota: true,
show_tokens: true,
show_ports: true,
show_sessions: true,
config_open: false,
config_selected: 0,
}
}

pub fn toggle_panel(&mut self, panel: u8) {
match panel {
1 => self.show_context = !self.show_context,
2 => self.show_quota = !self.show_quota,
3 => self.show_tokens = !self.show_tokens,
4 => self.show_ports = !self.show_ports,
5 => self.show_sessions = !self.show_sessions,
_ => {}
}
}

pub fn toggle_config(&mut self) {
self.config_open = !self.config_open;
if self.config_open {
self.config_selected = 0;
}
}

pub fn config_item_count(&self) -> usize {
6 // theme + 5 panel toggles
}

pub fn config_select_next(&mut self) {
if self.config_selected + 1 < self.config_item_count() {
self.config_selected += 1;
}
}

pub fn config_select_prev(&mut self) {
self.config_selected = self.config_selected.saturating_sub(1);
}

pub fn config_toggle_selected(&mut self) {
match self.config_selected {
0 => self.cycle_theme(),
1 => self.show_context = !self.show_context,
2 => self.show_quota = !self.show_quota,
3 => self.show_tokens = !self.show_tokens,
4 => self.show_ports = !self.show_ports,
5 => self.show_sessions = !self.show_sessions,
_ => {}
}
}

Expand Down
40 changes: 26 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,32 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, demo_mode: boo
let had_input = if event::poll(render_interval)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => app.quit(),
KeyCode::Char('r') if !demo_mode => app.tick(),
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Up | KeyCode::Char('k') => app.select_prev(),
KeyCode::Char('x') if !demo_mode => app.kill_selected(),
KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(),
KeyCode::Char('t') => app.cycle_theme(),
KeyCode::Enter if !demo_mode => {
if let Some(msg) = app.jump_to_session() {
app.set_status(msg);
}
},
_ => {}
if app.config_open {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.toggle_config(),
KeyCode::Down | KeyCode::Char('j') => app.config_select_next(),
KeyCode::Up | KeyCode::Char('k') => app.config_select_prev(),
KeyCode::Enter | KeyCode::Char(' ') => app.config_toggle_selected(),
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') => app.quit(),
KeyCode::Char('r') if !demo_mode => app.tick(),
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Up | KeyCode::Char('k') => app.select_prev(),
KeyCode::Char('x') if !demo_mode => app.kill_selected(),
KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(),
KeyCode::Char('t') => app.cycle_theme(),
KeyCode::Char(c @ '1'..='5') => app.toggle_panel(c as u8 - b'0'),
KeyCode::Char('c') => app.toggle_config(),
KeyCode::Enter if !demo_mode => {
if let Some(msg) = app.jump_to_session() {
app.set_status(msg);
}
},
_ => {}
}
}
}
}
Expand Down
87 changes: 87 additions & 0 deletions src/ui/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use crate::app::App;
use crate::theme::Theme;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use ratatui::Frame;

pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) {
let area = f.area();

let popup_w = 50u16.min(area.width.saturating_sub(4));
let popup_h = 14u16.min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(popup_w)) / 2;
let y = (area.height.saturating_sub(popup_h)) / 2;
let popup = Rect::new(x, y, popup_w, popup_h);

f.render_widget(Clear, popup);

let block = Block::default()
.title(Line::from(vec![
Span::styled(
" Config ",
Style::default().fg(theme.title).add_modifier(Modifier::BOLD),
),
]).alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.cpu_box));
f.render_widget(block, popup);

let inner = Rect::new(popup.x + 2, popup.y + 1, popup.width.saturating_sub(4), popup.height.saturating_sub(2));

let items: Vec<(&str, String)> = vec![
("Theme", app.theme.name.to_string()),
("Context panel (1)", toggle_str(app.show_context)),
("Quota panel (2)", toggle_str(app.show_quota)),
("Tokens panel (3)", toggle_str(app.show_tokens)),
("Ports panel (4)", toggle_str(app.show_ports)),
("Sessions panel (5)", toggle_str(app.show_sessions)),
];

let mut lines = Vec::new();
lines.push(Line::from(""));

for (i, (label, value)) in items.iter().enumerate() {
let selected = i == app.config_selected;
let cursor = if selected { ">" } else { " " };

let label_style = if selected {
Style::default().fg(theme.selected_fg).bg(theme.selected_bg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.main_fg)
};

let value_style = if selected {
Style::default().fg(theme.selected_fg).bg(theme.selected_bg)
} else if value == "on" {
Style::default().fg(theme.proc_misc)
} else if value == "off" {
Style::default().fg(theme.inactive_fg)
} else {
Style::default().fg(theme.session_id)
};

let label_w = 22;
let padded_label = format!("{} {:<width$}", cursor, label, width = label_w);
let padded_value = format!("{:<10}", value);

lines.push(Line::from(vec![
Span::styled(padded_label, label_style),
Span::styled(padded_value, value_style),
]));
}

lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" abtop v{} Enter/Space to change Esc to close", env!("CARGO_PKG_VERSION")),
Style::default().fg(theme.graph_text),
)));

f.render_widget(Paragraph::new(lines), inner);
}

fn toggle_str(v: bool) -> String {
if v { "on".into() } else { "off".into() }
}
4 changes: 4 additions & 0 deletions src/ui/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) {
spans.push(Span::styled(" refresh ", Style::default().fg(theme.main_fg)));
spans.push(Span::styled("t", Style::default().fg(theme.hi_fg)));
spans.push(Span::styled(" theme ", Style::default().fg(theme.main_fg)));
spans.push(Span::styled("1-5", Style::default().fg(theme.hi_fg)));
spans.push(Span::styled(" panels ", Style::default().fg(theme.main_fg)));
spans.push(Span::styled("c", Style::default().fg(theme.hi_fg)));
spans.push(Span::styled(" config ", Style::default().fg(theme.main_fg)));

// Show transient status message or default "2s auto"
let status_text = app.status_msg.as_ref()
Expand Down
71 changes: 48 additions & 23 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod config;
mod context;
mod footer;
mod header;
Expand Down Expand Up @@ -283,29 +284,41 @@ pub fn draw(f: &mut Frame, app: &App) {
}

// Layout priority: sessions first → mid → context (only with surplus space)
// Sessions get their full ideal height before anything else.

const CONTEXT_MIN: u16 = 5;
const FIXED: u16 = 2; // header + footer

// Projects panel is always visible, so the mid row always renders.
let any_mid = true;

let mid_h_ideal: u16 = 8;
// Sessions: border(2) + header(1) + 2 rows/session + detail area
let sessions_ideal: u16 = (app.sessions.len() as u16 * 2 + 7).max(8);
let sessions_ideal: u16 = if app.show_sessions {
(app.sessions.len() as u16 * 2 + 7).max(8)
} else {
0
};
let context_ideal: u16 = (app.sessions.len() as u16 + 4).clamp(5, 10);

let available = h.saturating_sub(FIXED);
const MID_MIN: u16 = 6;
// 1) Reserve mid minimum first, then sessions get the rest
let mid_reserved = MID_MIN.min(available);
let mid_reserved = if any_mid { MID_MIN.min(available) } else { 0 };
let sessions_budget = available.saturating_sub(mid_reserved);
let sessions_h = sessions_ideal.min(sessions_budget).max(5.min(sessions_budget));
// 2) Mid gets ideal from remaining (at least the reserved minimum)
let sessions_h = if app.show_sessions {
sessions_ideal.min(sessions_budget).max(5.min(sessions_budget))
} else {
0
};
let after_sessions = available.saturating_sub(sessions_h);
let mid_h = mid_h_ideal.min(after_sessions).max(mid_reserved.min(after_sessions));
// 3) Context only if sessions are fully satisfied and surplus >= CONTEXT_MIN
let mid_h = if any_mid {
mid_h_ideal.min(after_sessions).max(mid_reserved.min(after_sessions))
} else {
0
};
let surplus = available.saturating_sub(sessions_h + mid_h);
let context_h = if sessions_h >= sessions_ideal && surplus >= CONTEXT_MIN {
let context_h = if app.show_context && sessions_h >= sessions_ideal && surplus >= CONTEXT_MIN {
context_ideal.min(surplus)
} else if app.show_context && !app.show_sessions && surplus >= CONTEXT_MIN {
context_ideal.min(available.saturating_sub(mid_h))
} else {
0
};
Expand All @@ -319,7 +332,9 @@ pub fn draw(f: &mut Frame, app: &App) {
if mid_h > 0 {
constraints[n] = Constraint::Length(mid_h); n += 1;
}
constraints[n] = Constraint::Min(sessions_h); n += 1;
if sessions_h > 0 {
constraints[n] = Constraint::Min(sessions_h); n += 1;
}
constraints[n] = Constraint::Length(1); // footer
n += 1;

Expand All @@ -338,26 +353,36 @@ pub fn draw(f: &mut Frame, app: &App) {
}

if mid_h > 0 {
let mut mid_constraints: Vec<Constraint> = Vec::new();
if app.show_quota { mid_constraints.push(Constraint::Length(0)); }
if app.show_tokens { mid_constraints.push(Constraint::Length(0)); }
mid_constraints.push(Constraint::Length(0)); // projects always visible
if app.show_ports { mid_constraints.push(Constraint::Length(0)); }
let count = mid_constraints.len() as u32;
let mid_constraints: Vec<Constraint> = (0..count).map(|_| Constraint::Ratio(1, count)).collect();

let mid_panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25), // quota (rate limit)
Constraint::Percentage(25), // tokens
Constraint::Percentage(25), // projects
Constraint::Percentage(25), // ports
])
.constraints(mid_constraints)
.split(chunks[idx]);

quota::draw_quota_panel(f, app, mid_panels[0], theme);
tokens::draw_tokens_panel(f, app, mid_panels[1], theme);
projects::draw_projects_panel(f, app, mid_panels[2], theme);
ports::draw_ports_panel(f, app, mid_panels[3], theme);
let mut mi = 0;
if app.show_quota { quota::draw_quota_panel(f, app, mid_panels[mi], theme); mi += 1; }
if app.show_tokens { tokens::draw_tokens_panel(f, app, mid_panels[mi], theme); mi += 1; }
projects::draw_projects_panel(f, app, mid_panels[mi], theme); mi += 1;
if app.show_ports { ports::draw_ports_panel(f, app, mid_panels[mi], theme); }
idx += 1;
}

sessions::draw_sessions_panel(f, app, chunks[idx], theme);
idx += 1;
if sessions_h > 0 {
sessions::draw_sessions_panel(f, app, chunks[idx], theme);
idx += 1;
}
footer::draw_footer(f, app, chunks[idx], theme);

if app.config_open {
config::draw_config_overlay(f, app, theme);
}
}

// ── utility functions ────────────────────────────────────────────────────────
Expand Down
Loading