use std::collections::HashMap;
use std::io::{self, Stdout};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use crate::duration::{format_duration, parse_duration};
use crate::paths::Context;
use crate::status::{AgentStatus, Source};
use crate::store::ListedSession;
use crate::tmux::{self, Pane};
use crate::{attach, db, git, list, pick, resume};
const TICK: Duration = Duration::from_millis(200);
const REFRESH: Duration = Duration::from_secs(1);
const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(150);
const DEFAULT_IDLE: Duration = Duration::from_secs(20 * 60);
const CACHE_CAP: usize = 64;
const HINTS: &str = "q quit · j/k move · / filter · ga/gr view · s sort · a attach · r resume · d delete · I idle · = zoom · ␣gg lazygit · br/bp browse";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Sort {
Created,
Activity,
Provider,
Status,
Directory,
}
impl Sort {
fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::Activity => "activity",
Self::Provider => "provider",
Self::Status => "status",
Self::Directory => "directory",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum View {
Active,
Resumable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Input {
Normal,
Filter,
Idle,
Confirm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pending {
G,
S,
Space,
SpaceG,
B,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Browse {
Repo,
Pr,
}
#[derive(Debug)]
pub(crate) enum Effect {
None,
Quit,
Attach(Pane),
Resume(ListedSession),
Remove(ListedSession),
Lazygit(PathBuf),
Browse(PathBuf, Browse),
}
enum Fetch {
Refresh(Box<RefreshRequest>),
Preview { pane_id: String, generation: u64 },
Git { cwd: String, generation: u64 },
}
struct RefreshRequest {
ctx: Context,
resumable: bool,
idle: Option<Duration>,
generation: u64,
}
#[derive(Clone)]
struct CachedGit {
branch: Option<String>,
info: Option<git::RepoInfo>,
}
enum Fetched {
Refresh {
generation: u64,
result: Result<(Vec<ListedSession>, Vec<Pane>)>,
},
Preview {
generation: u64,
pane_id: String,
content: Option<String>,
},
Git {
generation: u64,
cwd: String,
branch: Option<String>,
info: Option<git::RepoInfo>,
},
}
fn spawn_fetch(tx: &Sender<Fetched>, fetch: Fetch) {
let tx = tx.clone();
thread::spawn(move || {
let fetched = match fetch {
Fetch::Refresh(request) => {
let RefreshRequest {
ctx,
resumable,
idle,
generation,
} = *request;
let result = (|| {
let sessions = db::query_sessions(&ctx, resumable, idle)?;
let panes = tmux::list_panes(true).unwrap_or_default();
Ok((sessions, panes))
})();
Fetched::Refresh { generation, result }
}
Fetch::Preview {
pane_id,
generation,
} => {
let content = tmux::capture_pane(&pane_id).ok();
Fetched::Preview {
generation,
pane_id,
content,
}
}
Fetch::Git { cwd, generation } => {
let path = Path::new(&cwd);
let branch = git::branch(path);
let info = git::repo_info(path);
Fetched::Git {
generation,
cwd,
branch,
info,
}
}
};
let _ = tx.send(fetched);
});
}
struct App {
view: View,
sort: Sort,
sessions: Vec<ListedSession>,
panes: Vec<Pane>,
selected: usize,
selected_pane: Option<Pane>,
preview: Option<String>,
preview_pending_since: Option<Instant>,
preview_in_flight: Option<u64>,
preview_maximized: bool,
filter: String,
input: Input,
idle_enabled: bool,
idle: Duration,
idle_input: String,
confirm_session: Option<ListedSession>,
pending: Option<Pending>,
git: Option<git::RepoInfo>,
branch: Option<String>,
git_cwd: Option<String>,
git_pending_since: Option<Instant>,
message: Option<String>,
dirty: bool,
needs_redraw: bool,
fetch_tx: Sender<Fetched>,
fetch_rx: Receiver<Fetched>,
refresh_generation: u64,
preview_generation: u64,
git_generation: u64,
previews: HashMap<String, String>,
gits: HashMap<String, CachedGit>,
}
impl App {
fn new() -> Self {
let (fetch_tx, fetch_rx) = mpsc::channel();
Self {
view: View::Active,
sort: Sort::Created,
sessions: Vec::new(),
panes: Vec::new(),
selected: 0,
selected_pane: None,
preview: None,
preview_pending_since: None,
preview_in_flight: None,
preview_maximized: false,
filter: String::new(),
input: Input::Normal,
idle_enabled: false,
idle: DEFAULT_IDLE,
idle_input: String::new(),
confirm_session: None,
pending: None,
git: None,
branch: None,
git_cwd: None,
git_pending_since: None,
message: None,
dirty: false,
needs_redraw: true,
fetch_tx,
fetch_rx,
refresh_generation: 0,
preview_generation: 0,
git_generation: 0,
previews: HashMap::new(),
gits: HashMap::new(),
}
}
fn filtered(&self) -> Vec<&ListedSession> {
self.sessions
.iter()
.filter(|session| {
self.filter.is_empty() || pick::matches(session, std::slice::from_ref(&self.filter))
})
.collect()
}
fn selected_session(&self) -> Option<&ListedSession> {
self.filtered().get(self.selected).copied()
}
fn refresh(&mut self, ctx: &Context) {
self.refresh_generation += 1;
let (resumable, idle) = match self.view {
View::Active => (false, None),
View::Resumable => (true, self.idle_enabled.then_some(self.idle)),
};
spawn_fetch(
&self.fetch_tx,
Fetch::Refresh(Box::new(RefreshRequest {
ctx: ctx.clone(),
resumable,
idle,
generation: self.refresh_generation,
})),
);
}
fn drain_fetched(&mut self) {
while let Ok(fetched) = self.fetch_rx.try_recv() {
self.handle_fetched(fetched);
}
}
fn handle_fetched(&mut self, fetched: Fetched) {
match fetched {
Fetched::Refresh { generation, result } => {
if generation != self.refresh_generation {
return;
}
match result {
Ok((sessions, panes)) => self.apply_refresh(sessions, panes),
Err(err) => {
self.message = Some(format!("{err:#}"));
self.needs_redraw = true;
}
}
}
Fetched::Preview {
generation,
pane_id,
content,
} => {
if self.preview_in_flight != Some(generation) {
return;
}
self.preview_in_flight = None;
let Some(content) = content else {
return;
};
if self.previews.get(&pane_id) != Some(&content) {
if self.previews.len() >= CACHE_CAP && !self.previews.contains_key(&pane_id) {
self.previews.clear();
}
self.previews.insert(pane_id.clone(), content.clone());
}
if self
.selected_pane
.as_ref()
.is_some_and(|pane| pane.id == pane_id)
&& self.preview.as_ref() != Some(&content)
{
self.preview = Some(content);
self.needs_redraw = true;
}
}
Fetched::Git {
generation,
cwd,
branch,
info,
} => {
if self.gits.len() >= CACHE_CAP {
self.gits.clear();
}
self.gits.insert(
cwd.clone(),
CachedGit {
branch: branch.clone(),
info: info.clone(),
},
);
if generation != self.git_generation {
return;
}
if self.git_cwd.as_deref() == Some(cwd.as_str()) {
self.branch = branch;
self.git = info;
self.needs_redraw = true;
}
}
}
}
fn apply_refresh(&mut self, sessions: Vec<ListedSession>, panes: Vec<Pane>) {
let keep = self
.selected_session()
.map(|session| (session.provider.clone(), session.session_id.clone()));
self.needs_redraw = true;
self.panes = panes;
self.sessions = sessions;
sort_sessions(&mut self.sessions, self.sort);
self.restore_selection(keep);
if self.selected_pane.is_some() && self.preview_in_flight.is_none() {
self.preview_pending_since.get_or_insert_with(Instant::now);
}
if self.git_cwd.is_some() {
self.git_pending_since.get_or_insert_with(Instant::now);
}
self.request_git();
}
fn restore_selection(&mut self, keep: Option<(String, String)>) {
if let Some((provider, session_id)) = keep
&& let Some(pos) = self.filtered().iter().position(|session| {
session.provider == provider && session.session_id == session_id
})
{
self.selected = pos;
}
self.clamp_selection();
self.update_selection();
}
fn clamp_selection(&mut self) {
let len = self.filtered().len();
self.selected = self.selected.min(len.saturating_sub(1));
}
fn update_selection(&mut self) {
let pane = self
.selected_session()
.and_then(|session| attach::pane_for_session(&self.panes, session))
.cloned();
self.select_preview(pane);
let cwd = self
.selected_session()
.and_then(|session| session.cwd.clone())
.filter(|cwd| !cwd.is_empty());
if cwd != self.git_cwd {
self.git_cwd = cwd;
let cached = self.git_cwd.as_ref().and_then(|cwd| self.gits.get(cwd));
self.branch = cached.and_then(|cached| cached.branch.clone());
self.git = cached.and_then(|cached| cached.info.clone());
self.git_pending_since = Some(Instant::now());
}
}
fn select_preview(&mut self, pane: Option<Pane>) {
if pane != self.selected_pane {
self.needs_redraw = true;
}
if pane.as_ref().map(|pane| &pane.id) != self.selected_pane.as_ref().map(|pane| &pane.id) {
self.preview_pending_since = pane.as_ref().map(|_| Instant::now());
self.preview = pane
.as_ref()
.and_then(|pane| self.previews.get(&pane.id).cloned());
}
self.selected_pane = pane;
}
fn next_preview_fetch(&mut self, now: Instant) -> Option<Fetch> {
if self.preview_in_flight.is_some()
|| !self
.preview_pending_since
.is_some_and(|since| now.duration_since(since) >= PREVIEW_DEBOUNCE)
{
return None;
}
self.preview_pending_since = None;
let pane = self.selected_pane.as_ref()?;
self.preview_generation += 1;
self.preview_in_flight = Some(self.preview_generation);
Some(Fetch::Preview {
pane_id: pane.id.clone(),
generation: self.preview_generation,
})
}
fn request_preview(&mut self) {
if let Some(fetch) = self.next_preview_fetch(Instant::now()) {
spawn_fetch(&self.fetch_tx, fetch);
}
}
fn request_git(&mut self) {
if self.git_pending_since.take().is_none() {
return;
}
let Some(cwd) = self.git_cwd.clone() else {
self.git = None;
self.branch = None;
return;
};
self.git_generation += 1;
spawn_fetch(
&self.fetch_tx,
Fetch::Git {
cwd,
generation: self.git_generation,
},
);
}
fn move_by(&mut self, delta: i32) {
let len = self.filtered().len() as i32;
if len == 0 {
return;
}
self.selected = (self.selected as i32 + delta).clamp(0, len - 1) as usize;
self.update_selection();
}
fn set_view(&mut self, view: View) {
if self.view == view {
return;
}
self.view = view;
self.selected = 0;
self.dirty = true;
}
fn set_sort(&mut self, sort: Sort) {
let keep = self
.selected_session()
.map(|session| (session.provider.clone(), session.session_id.clone()));
self.sort = sort;
sort_sessions(&mut self.sessions, sort);
self.restore_selection(keep);
}
fn handle_key(&mut self, key: KeyEvent) -> Effect {
if key.kind != KeyEventKind::Press {
return Effect::None;
}
self.needs_redraw = true;
self.message = None;
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Effect::Quit;
}
match self.input {
Input::Filter => return self.handle_filter_key(key),
Input::Idle => return self.handle_idle_key(key),
Input::Confirm => return self.handle_confirm_key(key),
Input::Normal => {}
}
if let Some(pending) = self.pending.take() {
match (pending, key.code) {
(Pending::G, KeyCode::Char('a')) => self.set_view(View::Active),
(Pending::G, KeyCode::Char('r')) => self.set_view(View::Resumable),
(Pending::S, KeyCode::Char('t')) => self.set_sort(Sort::Created),
(Pending::S, KeyCode::Char('r')) => self.set_sort(Sort::Activity),
(Pending::S, KeyCode::Char('a')) => self.set_sort(Sort::Provider),
(Pending::S, KeyCode::Char('s')) => self.set_sort(Sort::Status),
(Pending::S, KeyCode::Char('d')) => self.set_sort(Sort::Directory),
(Pending::Space, KeyCode::Char('g')) => self.pending = Some(Pending::SpaceG),
(Pending::SpaceG, KeyCode::Char('g')) => return self.open_lazygit(),
(Pending::B, KeyCode::Char('r')) => return self.browse_selected(Browse::Repo),
(Pending::B, KeyCode::Char('p')) => return self.browse_selected(Browse::Pr),
_ => {}
}
return Effect::None;
}
match key.code {
KeyCode::Char('q') => Effect::Quit,
KeyCode::Esc => {
if self.filter.is_empty() {
Effect::Quit
} else {
self.filter.clear();
self.clamp_selection();
self.update_selection();
Effect::None
}
}
KeyCode::Char('j') | KeyCode::Down => {
self.move_by(1);
Effect::None
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_by(-1);
Effect::None
}
KeyCode::Char('g') => {
self.pending = Some(Pending::G);
Effect::None
}
KeyCode::Char('s') => {
self.pending = Some(Pending::S);
Effect::None
}
KeyCode::Char(' ') => {
self.pending = Some(Pending::Space);
Effect::None
}
KeyCode::Char('b') => {
self.pending = Some(Pending::B);
Effect::None
}
KeyCode::Char('/') => {
self.input = Input::Filter;
Effect::None
}
KeyCode::Char('I') => {
self.toggle_idle();
Effect::None
}
KeyCode::Char('=') => {
self.toggle_maximized();
Effect::None
}
KeyCode::Char('a') => self.attach_selected(),
KeyCode::Char('r') => self.resume_selected(),
KeyCode::Char('d') => self.confirm_remove(),
_ => Effect::None,
}
}
fn handle_filter_key(&mut self, key: KeyEvent) -> Effect {
match key.code {
KeyCode::Enter => self.input = Input::Normal,
KeyCode::Esc => {
self.filter.clear();
self.input = Input::Normal;
self.selected = 0;
self.update_selection();
}
KeyCode::Backspace => {
self.filter.pop();
self.selected = 0;
self.update_selection();
}
KeyCode::Char(ch) => {
self.filter.push(ch);
self.selected = 0;
self.update_selection();
}
KeyCode::Up => self.move_by(-1),
KeyCode::Down => self.move_by(1),
_ => {}
}
Effect::None
}
fn handle_idle_key(&mut self, key: KeyEvent) -> Effect {
match key.code {
KeyCode::Enter => match parse_duration(&self.idle_input) {
Ok(idle) => {
self.idle = idle;
self.idle_enabled = true;
self.input = Input::Normal;
self.dirty = true;
}
Err(err) => self.message = Some(format!("{err:#}")),
},
KeyCode::Esc => self.input = Input::Normal,
KeyCode::Backspace => {
self.idle_input.pop();
}
KeyCode::Char(ch) => self.idle_input.push(ch),
_ => {}
}
Effect::None
}
fn handle_confirm_key(&mut self, key: KeyEvent) -> Effect {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.input = Input::Normal;
match self.confirm_session.take() {
Some(session) => Effect::Remove(session),
None => Effect::None,
}
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
self.input = Input::Normal;
self.confirm_session = None;
Effect::None
}
_ => Effect::None,
}
}
fn toggle_idle(&mut self) {
if self.view != View::Resumable {
self.message = Some("idle filter applies to the resumable list (gr)".into());
return;
}
if self.idle_enabled {
self.idle_enabled = false;
self.dirty = true;
} else {
self.idle_input = format_duration(self.idle);
self.input = Input::Idle;
}
}
fn toggle_maximized(&mut self) {
if self.selected_pane.is_none() {
self.message = Some("no tmux preview for the selected session".into());
return;
}
self.preview_maximized = !self.preview_maximized;
}
fn attach_selected(&mut self) -> Effect {
match self.selected_pane.clone() {
Some(pane) => Effect::Attach(pane),
None => {
self.message = Some("selected session is not in a tmux pane".into());
Effect::None
}
}
}
fn resume_selected(&mut self) -> Effect {
if self.view != View::Resumable {
self.message = Some("press gr to open resumable sessions".into());
return Effect::None;
}
match self.selected_session().cloned() {
Some(session) => Effect::Resume(session),
None => {
self.message = Some("no session selected".into());
Effect::None
}
}
}
fn confirm_remove(&mut self) -> Effect {
match self.selected_session().cloned() {
Some(session) => {
self.confirm_session = Some(session);
self.input = Input::Confirm;
}
None => self.message = Some("no session selected".into()),
}
Effect::None
}
fn open_lazygit(&mut self) -> Effect {
match self.selected_cwd() {
Some(cwd) => Effect::Lazygit(cwd),
None => {
self.message = Some("session has no working directory".into());
Effect::None
}
}
}
fn browse_selected(&mut self, target: Browse) -> Effect {
match self.selected_cwd() {
Some(cwd) => Effect::Browse(cwd, target),
None => {
self.message = Some("session has no working directory".into());
Effect::None
}
}
}
fn selected_cwd(&self) -> Option<PathBuf> {
self.selected_session()
.and_then(|session| session.cwd.clone())
.filter(|cwd| !cwd.is_empty())
.map(PathBuf::from)
}
}
struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
fn enter() -> Result<Self> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
let terminal = Terminal::new(CrosstermBackend::new(stdout)).context("init terminal")?;
Ok(Self { terminal })
}
fn suspend(&mut self) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
Ok(())
}
fn resume(&mut self) -> Result<()> {
enable_raw_mode().context("enable raw mode")?;
execute!(self.terminal.backend_mut(), EnterAlternateScreen)
.context("enter alternate screen")?;
self.terminal.clear().context("clear terminal")?;
Ok(())
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
}
}
pub fn run(ctx: &Context) -> Result<()> {
let mut guard = TerminalGuard::enter()?;
let mut app = App::new();
app.refresh(ctx);
let mut last_refresh = Instant::now();
loop {
if last_refresh.elapsed() >= REFRESH {
app.refresh(ctx);
last_refresh = Instant::now();
}
app.drain_fetched();
if app.needs_redraw {
guard.terminal.draw(|frame| render(frame, &app))?;
app.needs_redraw = false;
}
if event::poll(TICK).context("poll terminal events")? {
match event::read().context("read terminal event")? {
Event::Key(key) => match app.handle_key(key) {
Effect::None => {}
Effect::Quit => break,
Effect::Attach(pane) => attach_effect(&mut guard, &mut app, &pane),
Effect::Resume(session) => match resume_session(ctx, &session) {
Ok(pane) => attach_effect(&mut guard, &mut app, &pane),
Err(err) => app.message = Some(format!("{err:#}")),
},
Effect::Remove(session) => remove_effect(&mut app, ctx, &session),
Effect::Lazygit(cwd) => lazygit_effect(&mut guard, &mut app, &cwd),
Effect::Browse(cwd, target) => browse_effect(&mut app, &cwd, target),
},
Event::Resize(..) => app.needs_redraw = true,
_ => {}
}
}
if app.dirty {
app.refresh(ctx);
app.dirty = false;
last_refresh = Instant::now();
}
app.request_preview();
if app
.git_pending_since
.is_some_and(|since| since.elapsed() >= PREVIEW_DEBOUNCE)
{
app.request_git();
}
}
Ok(())
}
fn attach_effect(guard: &mut TerminalGuard, app: &mut App, pane: &Pane) {
let result = if std::env::var_os("TMUX").is_some() {
tmux::attach_pane(pane)
} else {
let _ = guard.suspend();
let result = tmux::attach_pane(pane);
let resumed = guard.resume();
result.and(resumed)
};
if let Err(err) = result {
app.message = Some(format!("attach: {err:#}"));
}
app.dirty = true;
}
fn lazygit_effect(guard: &mut TerminalGuard, app: &mut App, cwd: &Path) {
let _ = guard.suspend();
let result = std::process::Command::new("lazygit")
.current_dir(cwd)
.status()
.context("run lazygit")
.and_then(|status| {
if status.success() {
Ok(())
} else {
bail!("lazygit exited with {status}");
}
});
let resumed = guard.resume();
if let Err(err) = result.and(resumed) {
app.message = Some(format!("{err:#}"));
}
app.dirty = true;
}
fn browse_effect(app: &mut App, cwd: &Path, target: Browse) {
let args = match target {
Browse::Repo => ["repo", "view", "-w"],
Browse::Pr => ["pr", "view", "-w"],
};
match std::process::Command::new("gh")
.args(args)
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
app.message = Some(if detail.is_empty() {
format!("gh {} {} failed", args[0], args[1])
} else {
format!("gh: {detail}")
});
}
Err(err) => app.message = Some(format!("gh: {err:#}")),
}
}
fn resume_session(ctx: &Context, session: &ListedSession) -> Result<Pane> {
if session.source == Source::Desktop {
bail!("desktop sessions cannot be resumed");
}
let cwd = session
.cwd
.clone()
.filter(|cwd| !cwd.is_empty())
.context("session has no working directory")?;
if !tmux::available() {
bail!("tmux is required to resume CLI sessions");
}
let cwd = PathBuf::from(cwd);
let name = tmux::ensure_session(&cwd)?;
let window = resume::window_name(session);
let pane_id = tmux::spawn_window(&name, &window, &cwd, &session.cmdline)?;
db::mark_removed(ctx, &session.provider, &session.session_id)?;
tmux::find_pane(&pane_id)?.context("resumed pane not found")
}
fn remove_effect(app: &mut App, ctx: &Context, session: &ListedSession) {
match db::mark_removed(ctx, &session.provider, &session.session_id) {
Ok(()) => {
app.message = Some(format!(
"removed {} {}",
session.provider, session.session_id
));
app.dirty = true;
}
Err(err) => app.message = Some(format!("remove: {err:#}")),
}
}
fn render(frame: &mut Frame, app: &App) {
let area = frame.area();
if app.preview_maximized && app.selected_pane.is_some() {
render_preview(frame, app, area);
return;
}
let rows = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).split(area);
let cols =
Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).split(rows[0]);
render_list(frame, app, cols[0]);
if app.selected_pane.is_some() {
let right = Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(cols[1]);
render_details(frame, app, right[0]);
render_preview(frame, app, right[1]);
} else {
render_details(frame, app, cols[1]);
}
render_footer(frame, app, rows[1]);
}
fn render_list(frame: &mut Frame, app: &App, area: Rect) {
let sessions = app.filtered();
let title_width = (area.width as usize).saturating_sub(2 + 2 + 8 + 1).max(1);
let items: Vec<ListItem> = sessions
.iter()
.map(|session| {
let title = list::truncate(session.title.as_deref().unwrap_or("-"), title_width);
ListItem::new(Line::from(vec![
Span::styled(logo(&session.provider), Style::default().fg(Color::Magenta)),
Span::raw(" "),
Span::styled(
format!("{:<7}", session.status.as_str()),
status_style(session.status),
),
Span::raw(" "),
Span::raw(title),
]))
})
.collect();
let count = sessions.len();
let mut title = match app.view {
View::Active => format!(" Active ({count}) "),
View::Resumable => format!(" Resumable ({count}) "),
};
if app.view == View::Resumable && app.idle_enabled {
title = format!(
"{}· idle ≤ {} ",
title.trim_end(),
format_duration(app.idle)
);
}
if !app.filter.is_empty() {
title = format!("{}· /{} ", title.trim_end(), app.filter);
}
let list_widget = List::new(items)
.block(
Block::bordered()
.title(title)
.title_bottom(format!(" sort: {} ", app.sort.label())),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
if count > 0 {
state.select(Some(app.selected));
}
frame.render_stateful_widget(list_widget, area, &mut state);
}
fn render_details(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::bordered().title(" Details ");
let Some(session) = app.selected_session() else {
frame.render_widget(Paragraph::new("No session selected").block(block), area);
return;
};
let cwd = session.cwd.clone().unwrap_or_else(|| "-".into());
let branch = app
.git
.as_ref()
.map(|info| info.status.clone())
.or_else(|| app.branch.clone())
.unwrap_or_else(|| "-".into());
let age = list::age_label(session.last_report_ms);
let command = if session.cmdline.is_empty() {
"-".into()
} else {
session.cmdline.join(" ")
};
let mut rows = vec![
("provider", session.provider.clone()),
("session", session.session_id.clone()),
("status", session.status.as_str().to_string()),
("source", session.source.as_str().to_string()),
(
"pid",
session
.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "-".into()),
),
("cwd", cwd),
("branch", branch),
];
if let Some(repo) = app.git.as_ref().and_then(|info| info.github.as_ref()) {
rows.push(("repo", repo.clone()));
}
rows.extend([
("title", session.title.clone().unwrap_or_else(|| "-".into())),
("age", age),
("command", command),
]);
if let Some(pane) = &app.selected_pane {
rows.push((
"tmux",
format!("{}:{} ({})", pane.session, pane.window_name, pane.id),
));
}
let lines: Vec<Line> = rows
.into_iter()
.map(|(label, value)| {
Line::from(vec![
Span::styled(format!("{label:<9}"), Style::default().fg(Color::DarkGray)),
Span::raw(value),
])
})
.collect();
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn render_preview(frame: &mut Frame, app: &App, area: Rect) {
let title = match &app.selected_pane {
Some(pane) => format!(" {}:{} ({}) ", pane.session, pane.window_name, pane.id),
None => " Preview ".into(),
};
let text = app.preview.clone().unwrap_or_default();
let inner_height = area.height.saturating_sub(2) as usize;
let scroll = text.lines().count().saturating_sub(inner_height) as u16;
let preview = Paragraph::new(text)
.block(Block::bordered().title(title))
.scroll((scroll, 0));
frame.render_widget(preview, area);
}
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
match app.input {
Input::Filter => {
frame.render_widget(Paragraph::new(format!("/{}", app.filter)), area);
frame.set_cursor_position((area.x + 1 + app.filter.len() as u16, area.y));
}
Input::Idle => {
let prompt = format!("idle within: {}", app.idle_input);
frame.render_widget(Paragraph::new(prompt), area);
frame.set_cursor_position((area.x + 13 + app.idle_input.len() as u16, area.y));
}
Input::Confirm => {
let label = match &app.confirm_session {
Some(session) => format!("{} {}", session.provider, session.session_id),
None => "session".into(),
};
frame.render_widget(
Paragraph::new(format!("delete {label}? (y/N)"))
.style(Style::default().fg(Color::Red)),
area,
);
}
Input::Normal => {
let text = match app.pending {
Some(Pending::G) => "g — a active sessions · r resumable sessions".to_string(),
Some(Pending::S) => {
"s — t creation · r recent activity · a agent provider · s status · d directory"
.to_string()
}
Some(Pending::Space) | Some(Pending::SpaceG) => "␣g — g open lazygit".to_string(),
Some(Pending::B) => "b — r repo in browser · p pr in browser".to_string(),
None => app.message.clone().unwrap_or_else(|| HINTS.into()),
};
frame.render_widget(Paragraph::new(text), area);
}
}
}
fn sort_sessions(sessions: &mut [ListedSession], sort: Sort) {
sessions.sort_by(|a, b| {
let order = match sort {
Sort::Created => b.created_ms.cmp(&a.created_ms),
Sort::Activity => b.last_report_ms.cmp(&a.last_report_ms),
Sort::Provider => a.provider.cmp(&b.provider),
Sort::Status => a.status.as_str().cmp(b.status.as_str()),
Sort::Directory => (a.cwd.is_none(), &a.cwd).cmp(&(b.cwd.is_none(), &b.cwd)),
};
order
.then_with(|| b.created_ms.cmp(&a.created_ms))
.then_with(|| (&a.provider, &a.session_id).cmp(&(&b.provider, &b.session_id)))
});
}
fn logo(provider: &str) -> &'static str {
match provider {
"claude" => "✻",
"codex" => "⌘",
"grok" => "▲",
"opencode" => "◆",
"pi" => "π",
_ => "●",
}
}
fn status_style(status: AgentStatus) -> Style {
let color = match status {
AgentStatus::Working => Color::Green,
AgentStatus::Waiting => Color::Yellow,
AgentStatus::Idle => Color::Cyan,
AgentStatus::Done => Color::DarkGray,
};
Style::default().fg(color)
}
#[cfg(test)]
#[path = "tui_tests.rs"]
mod tests;