use std::time::{Duration, Instant};
use ansi_to_tui::IntoText;
use ratatui::text::Text;
use ratatui::widgets::ListState;
#[derive(Debug, Clone)]
pub struct TmuxPane {
pub id: String,
pub index: u32,
#[allow(dead_code)]
pub width: u32,
#[allow(dead_code)]
pub height: u32,
pub active: bool,
pub current_command: String,
pub pid: u32,
pub has_claude: bool,
}
#[derive(Debug, Clone)]
pub struct TmuxWindow {
pub index: u32,
pub name: String,
pub active: bool,
pub panes: Vec<TmuxPane>,
pub pane_width: u32,
pub pane_height: u32,
pub has_claude: bool,
}
impl TmuxWindow {
pub fn get_active_pane(&self) -> Option<&TmuxPane> {
self.panes.iter().find(|p| p.active).or(self.panes.first())
}
}
#[derive(Debug, Clone)]
pub struct TmuxSession {
pub name: String,
pub attached: bool,
pub unread: bool,
pub windows: Vec<TmuxWindow>,
pub has_claude: bool,
pub last_attached: i64,
pub activity: i64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ViewMode {
TreeView,
MultiPreview,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Focus {
Sessions,
Windows,
Panes,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InputMode {
Normal,
Input,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSortKey {
LastAttached,
Alphabet,
}
impl SessionSortKey {
pub fn label(self) -> &'static str {
match self {
SessionSortKey::LastAttached => "recent",
SessionSortKey::Alphabet => "abc",
}
}
fn cmp_ascending(self, a: &TmuxSession, b: &TmuxSession) -> std::cmp::Ordering {
match self {
SessionSortKey::LastAttached => a
.last_attached
.cmp(&b.last_attached)
.then_with(|| a.activity.cmp(&b.activity)),
SessionSortKey::Alphabet => a
.name
.to_lowercase()
.cmp(&b.name.to_lowercase()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Desc,
Asc,
}
impl SortDirection {
pub fn arrow(self) -> char {
match self {
SortDirection::Desc => '↓',
SortDirection::Asc => '↑',
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionSort {
pub key: SessionSortKey,
pub direction: SortDirection,
}
impl SessionSort {
pub const ALL: &'static [SessionSort] = &[
SessionSort {
key: SessionSortKey::LastAttached,
direction: SortDirection::Desc,
},
SessionSort {
key: SessionSortKey::LastAttached,
direction: SortDirection::Asc,
},
SessionSort {
key: SessionSortKey::Alphabet,
direction: SortDirection::Desc,
},
SessionSort {
key: SessionSortKey::Alphabet,
direction: SortDirection::Asc,
},
];
pub fn label(self) -> String {
format!("{}{}", self.key.label(), self.direction.arrow())
}
pub fn next(self) -> Self {
let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn apply(self, sessions: &mut [TmuxSession]) {
sessions.sort_by(|a, b| {
let ord = self.key.cmp_ascending(a, b);
let ord = match self.direction {
SortDirection::Desc => ord.reverse(),
SortDirection::Asc => ord,
};
ord.then_with(|| a.name.cmp(&b.name))
});
}
}
impl Default for SessionSort {
fn default() -> Self {
Self::ALL[0]
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PopupMode {
NewSession,
RenameSession,
ConfirmKill,
}
pub struct UIState {
pub view_mode: ViewMode,
pub last_space_press: Option<Instant>,
pub sessions: Vec<TmuxSession>,
pub selected_session: usize,
pub selected_window: usize,
pub selected_pane: usize,
pub focus: Focus,
pub session_list_state: ListState,
pub window_list_state: ListState,
pub pane_list_state: ListState,
pub session_sort: SessionSort,
pub multi_session: usize,
pub multi_window: usize,
pub pane_content: String,
pub pane_content_parsed: Option<Text<'static>>,
pub last_error: Option<String>,
#[allow(dead_code)]
pub interval: Duration,
pub input_mode: InputMode,
pub input_buffer: String,
pub input_cursor: usize,
pub popup_mode: Option<PopupMode>,
pub confirm_yes_selected: bool,
}
impl UIState {
pub fn new(interval_ms: u64) -> Self {
let mut state = Self {
view_mode: ViewMode::TreeView,
last_space_press: None,
sessions: Vec::new(),
selected_session: 0,
selected_window: 0,
selected_pane: 0,
focus: Focus::Sessions,
session_list_state: ListState::default(),
window_list_state: ListState::default(),
pane_list_state: ListState::default(),
session_sort: SessionSort::default(),
multi_session: 0,
multi_window: 0,
pane_content: String::new(),
pane_content_parsed: None,
last_error: None,
interval: Duration::from_millis(interval_ms),
input_mode: InputMode::Normal,
input_buffer: String::new(),
input_cursor: 0,
popup_mode: None,
confirm_yes_selected: false,
};
state.session_list_state.select(Some(0));
state.window_list_state.select(Some(0));
state.pane_list_state.select(Some(0));
state
}
pub fn handle_space_press(&mut self) -> bool {
let now = Instant::now();
if let Some(last) = self.last_space_press
&& now.duration_since(last) < Duration::from_millis(300)
{
self.toggle_view_mode();
self.last_space_press = None;
return true;
}
self.last_space_press = Some(now);
false
}
pub fn toggle_view_mode(&mut self) {
self.view_mode = match self.view_mode {
ViewMode::TreeView => {
self.multi_session = self.selected_session;
self.multi_window = self.selected_window;
ViewMode::MultiPreview
}
ViewMode::MultiPreview => {
self.selected_session = self.multi_session;
self.selected_window = self.multi_window;
self.selected_pane = 0;
self.session_list_state.select(Some(self.selected_session));
self.window_list_state.select(Some(self.selected_window));
self.pane_list_state.select(Some(0));
ViewMode::TreeView
}
};
}
pub fn enter_input_mode(&mut self) {
self.input_mode = InputMode::Input;
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn exit_input_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn get_current_target(&self) -> Option<String> {
match self.view_mode {
ViewMode::TreeView => self.get_selected_pane_target(),
ViewMode::MultiPreview => self.get_multi_selected_target(),
}
}
pub fn get_enter_target(&self) -> Option<String> {
match self.view_mode {
ViewMode::TreeView => match self.focus {
Focus::Sessions => self
.sessions
.get(self.selected_session)
.map(|s| s.name.clone()),
Focus::Windows => {
let session = self.sessions.get(self.selected_session)?;
let window = session.windows.get(self.selected_window)?;
Some(format!("{}:{}", session.name, window.index))
}
Focus::Panes => self.get_selected_pane_target(),
},
ViewMode::MultiPreview => self.get_multi_selected_target(),
}
}
pub fn input_char(&mut self, c: char) {
self.input_buffer.insert(self.input_cursor, c);
self.input_cursor += 1;
}
pub fn input_backspace(&mut self) {
if self.input_cursor > 0 {
self.input_cursor -= 1;
self.input_buffer.remove(self.input_cursor);
}
}
pub fn input_delete(&mut self) {
if self.input_cursor < self.input_buffer.len() {
self.input_buffer.remove(self.input_cursor);
}
}
pub fn input_move_left(&mut self) {
if self.input_cursor > 0 {
self.input_cursor -= 1;
}
}
pub fn input_move_right(&mut self) {
if self.input_cursor < self.input_buffer.len() {
self.input_cursor += 1;
}
}
pub fn input_move_home(&mut self) {
self.input_cursor = 0;
}
pub fn input_move_end(&mut self) {
self.input_cursor = self.input_buffer.len();
}
pub fn open_new_session_popup(&mut self) {
self.popup_mode = Some(PopupMode::NewSession);
self.input_buffer.clear();
self.input_cursor = 0;
}
pub fn open_rename_session_popup(&mut self) {
if let Some(session) = self.sessions.get(self.selected_session) {
self.popup_mode = Some(PopupMode::RenameSession);
self.input_buffer = session.name.clone();
self.input_cursor = self.input_buffer.len();
}
}
pub fn open_kill_session_popup(&mut self) {
if !self.sessions.is_empty() {
self.popup_mode = Some(PopupMode::ConfirmKill);
self.confirm_yes_selected = false; }
}
pub fn close_popup(&mut self) {
self.popup_mode = None;
self.input_buffer.clear();
self.input_cursor = 0;
self.confirm_yes_selected = false;
}
pub fn toggle_confirm_selection(&mut self) {
self.confirm_yes_selected = !self.confirm_yes_selected;
}
pub fn get_new_session_name(&self) -> String {
self.input_buffer.trim().to_string()
}
pub fn get_rename_session_info(&self) -> Option<(String, String)> {
let new_name = self.input_buffer.trim().to_string();
if new_name.is_empty() {
return None;
}
self.sessions
.get(self.selected_session)
.map(|s| (s.name.clone(), new_name))
}
pub fn get_kill_session_name(&self) -> Option<String> {
if self.confirm_yes_selected {
self.sessions
.get(self.selected_session)
.map(|s| s.name.clone())
} else {
None
}
}
pub fn update_sessions(&mut self, sessions: Vec<TmuxSession>) {
let current_name = self
.sessions
.get(self.selected_session)
.map(|s| s.name.clone());
self.sessions = sessions;
self.session_sort.apply(&mut self.sessions);
if let Some(name) = current_name
&& let Some(idx) = self.sessions.iter().position(|s| s.name == name)
{
self.selected_session = idx;
}
self.validate_selections();
self.last_error = None;
}
pub fn cycle_session_sort(&mut self) {
self.session_sort = self.session_sort.next();
self.resort_sessions_preserve_selection();
}
fn resort_sessions_preserve_selection(&mut self) {
let current_name = self
.sessions
.get(self.selected_session)
.map(|s| s.name.clone());
self.session_sort.apply(&mut self.sessions);
if let Some(name) = current_name
&& let Some(idx) = self.sessions.iter().position(|s| s.name == name)
{
self.selected_session = idx;
self.multi_session = self.multi_session.min(self.sessions.len().saturating_sub(1));
self.session_list_state.select(Some(idx));
}
}
pub fn update_pane_content(&mut self, content: String) {
self.pane_content_parsed = content.as_bytes().into_text().ok();
self.pane_content = content;
}
pub fn set_error(&mut self, message: String) {
self.last_error = Some(message);
}
pub fn validate_selections(&mut self) {
if !self.sessions.is_empty() {
self.selected_session = self.selected_session.min(self.sessions.len() - 1);
self.multi_session = self.multi_session.min(self.sessions.len() - 1);
if let Some(session) = self.sessions.get(self.selected_session)
&& !session.windows.is_empty()
{
self.selected_window = self.selected_window.min(session.windows.len() - 1);
if let Some(window) = session.windows.get(self.selected_window)
&& !window.panes.is_empty()
{
self.selected_pane = self.selected_pane.min(window.panes.len() - 1);
}
}
if let Some(session) = self.sessions.get(self.multi_session)
&& !session.windows.is_empty()
{
self.multi_window = self.multi_window.min(session.windows.len() - 1);
}
self.session_list_state.select(Some(self.selected_session));
self.window_list_state.select(Some(self.selected_window));
self.pane_list_state.select(Some(self.selected_pane));
} else {
self.session_list_state.select(None);
self.window_list_state.select(None);
self.pane_list_state.select(None);
}
}
pub fn get_selected_pane_target(&self) -> Option<String> {
let session = self.sessions.get(self.selected_session)?;
let window = session.windows.get(self.selected_window)?;
let pane = window.panes.get(self.selected_pane)?;
Some(format!("{}:{}.{}", session.name, window.index, pane.index))
}
pub fn get_selected_pane_target_with_capture_range(&self) -> Option<(String, i32, i32)> {
let session = self.sessions.get(self.selected_session)?;
let window = session.windows.get(self.selected_window)?;
let pane = window.panes.get(self.selected_pane)?;
let target = format!("{}:{}.{}", session.name, window.index, pane.index);
let height = i32::try_from(pane.height).unwrap_or(i32::MAX);
let start = 0;
let end = height;
Some((target, start, end))
}
pub fn tree_move_up(&mut self) {
match self.focus {
Focus::Sessions => {
if self.selected_session > 0 {
self.selected_session -= 1;
self.selected_window = 0;
self.selected_pane = 0;
self.window_list_state.select(Some(0));
self.pane_list_state.select(Some(0));
}
self.session_list_state.select(Some(self.selected_session));
}
Focus::Windows => {
if self.selected_window > 0 {
self.selected_window -= 1;
self.selected_pane = 0;
self.pane_list_state.select(Some(0));
}
self.window_list_state.select(Some(self.selected_window));
}
Focus::Panes => {
if self.selected_pane > 0 {
self.selected_pane -= 1;
}
self.pane_list_state.select(Some(self.selected_pane));
}
}
}
pub fn tree_move_down(&mut self) {
match self.focus {
Focus::Sessions => {
if self.selected_session < self.sessions.len().saturating_sub(1) {
self.selected_session += 1;
self.selected_window = 0;
self.selected_pane = 0;
self.window_list_state.select(Some(0));
self.pane_list_state.select(Some(0));
}
self.session_list_state.select(Some(self.selected_session));
}
Focus::Windows => {
if let Some(session) = self.sessions.get(self.selected_session)
&& self.selected_window < session.windows.len().saturating_sub(1)
{
self.selected_window += 1;
self.selected_pane = 0;
self.pane_list_state.select(Some(0));
}
self.window_list_state.select(Some(self.selected_window));
}
Focus::Panes => {
if let Some(session) = self.sessions.get(self.selected_session)
&& let Some(window) = session.windows.get(self.selected_window)
&& self.selected_pane < window.panes.len().saturating_sub(1)
{
self.selected_pane += 1;
}
self.pane_list_state.select(Some(self.selected_pane));
}
}
}
pub fn tree_next_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Windows,
Focus::Windows => Focus::Panes,
Focus::Panes => Focus::Sessions,
};
}
pub fn tree_prev_focus(&mut self) {
self.focus = match self.focus {
Focus::Sessions => Focus::Panes,
Focus::Windows => Focus::Sessions,
Focus::Panes => Focus::Windows,
};
}
pub fn get_multi_selected_target(&self) -> Option<String> {
let session = self.sessions.get(self.multi_session)?;
let window = session.windows.get(self.multi_window)?;
Some(format!("{}:{}", session.name, window.index))
}
pub fn multi_move_left(&mut self) {
if self.multi_session > 0 {
self.multi_session -= 1;
self.multi_window = 0;
}
}
pub fn multi_move_right(&mut self) {
if self.multi_session < self.sessions.len().saturating_sub(1) {
self.multi_session += 1;
self.multi_window = 0;
}
}
pub fn multi_move_up(&mut self) {
if self.multi_window > 0 {
self.multi_window -= 1;
}
}
pub fn multi_move_down(&mut self) {
if let Some(session) = self.sessions.get(self.multi_session)
&& self.multi_window < session.windows.len().saturating_sub(1)
{
self.multi_window += 1;
}
}
}