use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::App;
use super::tabs::Tab;
use super::theme;
pub const TAB: usize = 8;
const REFRESH: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Target {
Pane { tab: usize, pane: usize },
Detached { tab: usize, name: String },
Remote(String),
Nowhere { running: bool },
}
fn target_in(tabs: &[Tab], pid: Option<u32>, resumed: &str) -> Option<Target> {
tabs.iter().enumerate().find_map(|(at, tab)| {
let pane = tab.panes.iter().position(|pane| {
pid.is_some_and(|pid| pane.agent() == pid)
|| pane.resumed.as_deref() == Some(resumed)
|| pane.rmux.as_deref() == Some(resumed)
});
if let Some(pane) = pane {
return Some(Target::Pane { tab: at, pane });
}
let shared = tab.shared.as_ref().filter(|_| tab.detached())?;
(pid.is_some_and(|pid| shared.pid == Some(pid)) || shared.name == resumed).then(|| {
Target::Detached {
tab: at,
name: shared.name.clone(),
}
})
})
}
pub(super) fn first_row(rows: u16, height: u16, cursor: Option<u16>, drawn: Option<u16>) -> u16 {
if rows <= height {
return 0;
}
let bottom = cursor.max(drawn).map_or(0, |row| row.saturating_add(1));
bottom.saturating_sub(height).min(rows - height)
}
fn last_drawn(screen: &vt100::Screen) -> Option<u16> {
let (rows, cols) = screen.size();
(0..rows).rev().find(|&row| {
(0..cols).any(|col| screen.cell(row, col).is_some_and(vt100::Cell::has_contents))
})
}
struct Window<'a> {
screen: &'a vt100::Screen,
top: u16,
}
impl tui_term::widget::Screen for Window<'_> {
type C = vt100::Cell;
fn cell(&self, row: u16, col: u16) -> Option<&vt100::Cell> {
self.screen.cell(row.checked_add(self.top)?, col)
}
fn hide_cursor(&self) -> bool {
self.screen.hide_cursor()
}
fn cursor_position(&self) -> (u16, u16) {
let (row, col) = tui_term::widget::Screen::cursor_position(self.screen);
(row.checked_sub(self.top).unwrap_or(u16::MAX), col)
}
}
#[derive(Default)]
pub struct Capture {
name: String,
parser: Option<vt100::Parser>,
last: Option<crate::rmux::Capture>,
failed: bool,
asked_at: Option<Instant>,
pending: Option<Receiver<Option<crate::rmux::Capture>>>,
}
impl Capture {
fn apply(&mut self, capture: crate::rmux::Capture) -> bool {
if self.last.as_ref() == Some(&capture) {
return false;
}
let parser = self
.parser
.get_or_insert_with(|| vt100::Parser::new(capture.rows, capture.cols, 0));
parser.screen_mut().set_size(capture.rows, capture.cols);
parser.process(b"\x1b[0m\x1b[H\x1b[2J");
parser.process(&capture.bytes);
match capture.cursor {
Some((row, col)) => {
parser.process(format!("\x1b[{};{}H\x1b[?25h", row + 1, col + 1).as_bytes());
}
None => parser.process(b"\x1b[?25l"),
}
self.last = Some(capture);
true
}
}
impl App {
pub(super) fn preview_target(&self) -> Option<Target> {
let session = self.selected_session()?;
if let Some(remote) = &session.remote {
return Some(Target::Remote(remote.host.clone()));
}
let resumed = crate::rmux::name_for_session(session.provider.as_str(), &session.session_id);
Some(
target_in(&self.tabs, session.root_pid(), &resumed).unwrap_or(Target::Nowhere {
running: session.is_running(),
}),
)
}
pub(super) fn pump_preview(&mut self) -> bool {
if self.tab != 0 || self.bottom_tab != TAB {
return false;
}
let Some(Target::Detached { name, .. }) = self.preview_target() else {
return false;
};
let capture = &mut self.preview;
if capture.name != name {
*capture = Capture {
name: name.clone(),
..Capture::default()
};
}
let mut changed = false;
if let Some(pending) = &capture.pending {
match pending.try_recv() {
Ok(answer) => {
capture.pending = None;
changed = match answer {
Some(screen) => {
let drew = capture.apply(screen);
let recovered = std::mem::take(&mut capture.failed);
drew || recovered
}
None => !std::mem::replace(&mut capture.failed, true),
};
}
Err(TryRecvError::Empty) => return false,
Err(TryRecvError::Disconnected) => capture.pending = None,
}
}
if capture.asked_at.is_none_or(|at| at.elapsed() >= REFRESH) {
capture.asked_at = Some(Instant::now());
let (tx, rx) = mpsc::channel();
capture.pending = Some(rx);
std::thread::spawn(move || {
let _ = tx.send(crate::rmux::capture(&name));
});
}
changed
}
}
fn say(frame: &mut Frame, inner: Rect, text: String) {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(text, theme::dim()))),
inner,
);
}
fn alt_key(at: usize) -> Option<String> {
(at + 2 <= 9).then(|| format!("Alt+{}", at + 2))
}
pub(super) fn draw(frame: &mut Frame, area: Rect, inner: Rect, app: &App) {
let Some(target) = app.preview_target() else {
return;
};
let (screen, at) = match &target {
Target::Pane { tab, pane } => (app.tabs[*tab].panes[*pane].view.parser.screen(), *tab),
Target::Detached { tab, name } => {
let capture = &app.preview;
let ours = capture.name == *name;
if ours && capture.failed {
let how = alt_key(*tab).map_or(String::new(), |k| format!(" — {k} opens it"));
let label = app.tabs[*tab].title();
return say(
frame,
inner,
format!("rmux did not hand over {label}'s screen{how}"),
);
}
match &capture.parser {
Some(parser) if ours => (parser.screen(), *tab),
_ => return say(frame, inner, "Loading…".to_string()),
}
}
Target::Remote(host) => {
return say(
frame,
inner,
format!("This session is on {host}; cctop only hosts tabs for this machine."),
);
}
Target::Nowhere { running: true } => {
return say(
frame,
inner,
"Not in a tab here — a opens its terminal in one, and it shows here live."
.to_string(),
);
}
Target::Nowhere { running: false } => {
return say(
frame,
inner,
"Not in a tab here — R resumes it in one, and it shows here live.".to_string(),
);
}
};
let (rows, cols) = screen.size();
let cursor = (!screen.hide_cursor())
.then(|| tui_term::widget::Screen::cursor_position(screen).0)
.filter(|&row| row < rows);
let top = first_row(rows, inner.height, cursor, last_drawn(screen));
let area_drawn = Rect {
width: cols.min(inner.width),
height: rows.saturating_sub(top).min(inner.height),
..inner
};
frame.render_widget(
tui_term::widget::PseudoTerminal::new(&Window { screen, top }),
area_drawn,
);
let mut caption = format!(" {} · read-only", app.tabs[at].title());
if let Some(key) = alt_key(at) {
caption.push_str(&format!(" · {key} to drive"));
}
caption.push(' ');
if area.height >= 2 && area.width > 4 {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(caption, theme::dim())).right_aligned()),
Rect {
x: area.x + 2,
y: area.y + area.height - 1,
width: area.width - 4,
height: 1,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tabs::Pane;
fn running(name: &str, pid: Option<u32>) -> crate::rmux::Running {
crate::rmux::Running {
name: name.to_string(),
pid,
cwd: None,
attached: false,
activity: None,
label: Some("claude".into()),
profile: None,
order: None,
state: None,
color: None,
}
}
#[test]
fn the_panel_is_where_its_number_key_says() {
assert_eq!(super::super::panels::TABS[TAB], "Preview");
}
#[test]
fn a_row_finds_the_pane_its_agent_is_in() {
let pane = |pid| {
let mut pane = Pane::for_test("claude");
pane.pid = pid;
pane
};
let mut split = Tab::new(pane(1));
split.panes.push(pane(4321));
let tabs = vec![Tab::new(pane(2)), split];
assert_eq!(
target_in(&tabs, Some(4321), "cctop-claude-x"),
Some(Target::Pane { tab: 1, pane: 1 })
);
assert_eq!(target_in(&tabs, Some(99), "cctop-claude-x"), None);
}
#[test]
fn a_pane_just_resumed_is_found_before_its_agent_pid_is_known() {
let mut pane = Pane::for_test("claude");
pane.pid = 1;
pane.resumed = Some("cctop-claude-abc".into());
let tabs = vec![Tab::new(pane)];
assert_eq!(
target_in(&tabs, Some(4321), "cctop-claude-abc"),
Some(Target::Pane { tab: 0, pane: 0 })
);
assert_eq!(
target_in(&tabs, None, "cctop-claude-abc"),
Some(Target::Pane { tab: 0, pane: 0 })
);
assert_eq!(target_in(&tabs, None, "cctop-claude-def"), None);
}
#[test]
fn a_detached_tab_is_found_by_pid_or_by_session_name() {
let tabs = vec![
Tab::shared(&running("cctop-claude-one", Some(10))),
Tab::shared(&running("cctop-claude-two", None)),
];
assert_eq!(
target_in(&tabs, Some(10), "cctop-claude-zzz"),
Some(Target::Detached {
tab: 0,
name: "cctop-claude-one".into()
})
);
assert_eq!(
target_in(&tabs, Some(11), "cctop-claude-two"),
Some(Target::Detached {
tab: 1,
name: "cctop-claude-two".into()
})
);
assert_eq!(target_in(&tabs, None, "cctop-claude-zzz"), None);
}
#[test]
fn a_screen_that_fits_is_drawn_from_its_top() {
assert_eq!(first_row(10, 10, Some(9), Some(9)), 0);
assert_eq!(first_row(4, 10, None, None), 0);
}
#[test]
fn a_tall_screen_keeps_the_rows_the_agent_drew_last() {
assert_eq!(first_row(40, 8, Some(30), Some(33)), 26);
assert_eq!(first_row(40, 8, Some(39), Some(20)), 32);
assert_eq!(first_row(40, 8, Some(3), Some(2)), 0);
assert_eq!(first_row(40, 8, None, None), 0);
}
#[test]
fn the_window_shifts_cells_and_cursor_together() {
let mut parser = vt100::Parser::new(6, 10, 0);
parser.process(b"a\r\nb\r\nc\r\nd\r\ne\r\nf");
let screen = parser.screen();
assert_eq!(last_drawn(screen), Some(5));
let window = Window { screen, top: 3 };
use tui_term::widget::Screen;
assert_eq!(window.cell(0, 0).map(vt100::Cell::contents), Some("d"));
assert_eq!(window.cursor_position(), (2, 1));
parser.process(b"\x1b[1;1H");
let window = Window {
screen: parser.screen(),
top: 3,
};
assert_eq!(window.cursor_position().0, u16::MAX);
}
fn drawn(app: &mut App, (cols, rows): (u16, u16)) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
terminal
.draw(|frame| {
super::super::render::draw(frame, app);
})
.expect("draw");
let buffer = terminal.backend().buffer();
(0..rows)
.map(|y| (0..cols).map(|x| buffer[(x, y)].symbol()).collect())
.collect()
}
#[test]
fn the_panel_shows_the_bottom_of_the_tab_the_row_is_in() {
let mut app = super::super::tests::test_app();
app.sessions = vec![super::super::tests::session("abc", false, "/x")];
app.refilter();
let mut pane = Pane::for_test("claude");
pane.resumed = Some(crate::rmux::name_for_session("claude", "abc"));
let lines: Vec<String> = (0..24).map(|i| format!("line-{i:02}")).collect();
pane.view.parser.process(lines.join("\r\n").as_bytes());
let size = pane.view.size;
app.tabs.push(Tab::new(pane));
app.bottom_tab = TAB;
let screen = drawn(&mut app, (100, 40));
let text = screen.join("\n");
assert!(
text.contains("line-23"),
"the bottom row is missing:\n{text}"
);
assert!(
!text.contains("line-00"),
"the top should be clipped:\n{text}"
);
assert!(text.contains("read-only"), "no caption:\n{text}");
assert_eq!(app.tabs[0].panes[0].view.size, size, "the pane was resized");
}
#[test]
fn a_row_with_no_tab_says_how_to_open_one() {
let mut app = super::super::tests::test_app();
app.sessions = vec![super::super::tests::session("abc", false, "/x")];
app.refilter();
app.bottom_tab = TAB;
let text = drawn(&mut app, (100, 40)).join("\n");
assert!(text.contains("R resumes it in one"), "{text}");
}
#[test]
fn a_capture_replays_into_one_parser_and_skips_a_repeat() {
let mut capture = Capture::default();
let screen = crate::rmux::Capture {
cols: 20,
rows: 3,
cursor: Some((2, 4)),
bytes: b"one\r\n\x1b[31mtwo\r\nthree".to_vec(),
};
assert!(capture.apply(screen.clone()));
let parsed = capture.parser.as_ref().unwrap().screen();
assert_eq!(parsed.size(), (3, 20));
assert_eq!(parsed.cell(1, 0).unwrap().fgcolor(), vt100::Color::Idx(1));
assert_eq!(parsed.cursor_position(), (2, 4));
assert!(!capture.apply(screen), "an unchanged screen is not redrawn");
assert!(capture.apply(crate::rmux::Capture {
cols: 10,
rows: 2,
cursor: None,
bytes: b"x".to_vec(),
}));
let parsed = capture.parser.as_ref().unwrap().screen();
assert_eq!(parsed.size(), (2, 10));
assert_eq!(parsed.contents(), "x");
assert!(parsed.hide_cursor());
}
}