detached_shell/
interactive.rs

1use crate::{NdsError, Result, Session, SessionManager};
2use chrono::Timelike;
3use crossterm::{
4    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
5    execute,
6    terminal::{
7        self, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
8    },
9};
10use ratatui::{
11    backend::{Backend, CrosstermBackend},
12    layout::{Alignment, Constraint, Direction, Layout, Rect},
13    style::{Color, Modifier, Style},
14    text::{Line, Span},
15    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16    Frame, Terminal,
17};
18use std::{
19    io,
20    time::{Duration, Instant},
21};
22
23pub struct InteractivePicker {
24    sessions: Vec<Session>,
25    state: ListState,
26    current_session_id: Option<String>,
27}
28
29impl InteractivePicker {
30    pub fn new() -> Result<Self> {
31        let sessions = SessionManager::list_sessions()?;
32        if sessions.is_empty() {
33            return Err(NdsError::SessionNotFound("No active sessions".to_string()));
34        }
35
36        let mut state = ListState::default();
37        state.select(Some(0));
38
39        // Check if we're currently attached to a session
40        let mut current_session_id = std::env::var("NDS_SESSION_ID").ok();
41
42        // Fallback: If no environment variable, try to detect from parent processes
43        if current_session_id.is_none() {
44            current_session_id = Self::detect_current_session(&sessions);
45        }
46
47        Ok(Self {
48            sessions,
49            state,
50            current_session_id,
51        })
52    }
53
54    fn detect_current_session(sessions: &[Session]) -> Option<String> {
55        // Try to detect current session by checking parent processes
56        let mut ppid = std::process::id();
57
58        // Walk up the process tree (max 10 levels to avoid infinite loops)
59        for _ in 0..10 {
60            // Get parent process ID
61            let ppid_result = Self::get_parent_pid(ppid as i32);
62            if let Some(parent_pid) = ppid_result {
63                // Check if this PID matches any session
64                for session in sessions {
65                    if session.pid == parent_pid {
66                        return Some(session.id.clone());
67                    }
68                }
69                ppid = parent_pid as u32;
70            } else {
71                break;
72            }
73        }
74
75        None
76    }
77
78    fn get_parent_pid(pid: i32) -> Option<i32> {
79        // Read /proc/[pid]/stat on Linux or use ps on macOS
80        #[cfg(target_os = "macos")]
81        {
82            use std::process::Command;
83            let output = Command::new("ps")
84                .args(&["-p", &pid.to_string(), "-o", "ppid="])
85                .output()
86                .ok()?;
87
88            if output.status.success() {
89                let ppid_str = String::from_utf8_lossy(&output.stdout);
90                ppid_str.trim().parse::<i32>().ok()
91            } else {
92                None
93            }
94        }
95
96        #[cfg(target_os = "linux")]
97        {
98            use std::fs;
99            let stat_path = format!("/proc/{}/stat", pid);
100            let stat_content = fs::read_to_string(stat_path).ok()?;
101            let parts: Vec<&str> = stat_content.split_whitespace().collect();
102            // Parent PID is the 4th field in /proc/[pid]/stat
103            if parts.len() > 3 {
104                parts[3].parse::<i32>().ok()
105            } else {
106                None
107            }
108        }
109
110        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
111        {
112            None
113        }
114    }
115
116    pub fn run(&mut self) -> Result<Option<String>> {
117        // Setup terminal
118        enable_raw_mode()?;
119        let mut stdout = io::stdout();
120        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
121        let backend = CrosstermBackend::new(stdout);
122        let mut terminal = Terminal::new(backend)?;
123
124        let result = self.run_app(&mut terminal);
125
126        // Restore terminal
127        disable_raw_mode()?;
128        execute!(
129            terminal.backend_mut(),
130            LeaveAlternateScreen,
131            DisableMouseCapture
132        )?;
133        terminal.show_cursor()?;
134
135        result
136    }
137
138    fn run_app<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<Option<String>> {
139        let mut last_tick = Instant::now();
140        let tick_rate = Duration::from_millis(250);
141
142        loop {
143            terminal.draw(|f| self.ui(f))?;
144
145            let timeout = tick_rate
146                .checked_sub(last_tick.elapsed())
147                .unwrap_or_else(|| Duration::from_secs(0));
148
149            if crossterm::event::poll(timeout)? {
150                if let Event::Key(key) = event::read()? {
151                    if key.kind == KeyEventKind::Press {
152                        match key.code {
153                            KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
154                            KeyCode::Down | KeyCode::Char('j') => self.next(),
155                            KeyCode::Up | KeyCode::Char('k') => self.previous(),
156                            KeyCode::Enter => {
157                                if let Some(selected) = self.state.selected() {
158                                    return Ok(Some(self.sessions[selected].id.clone()));
159                                }
160                            }
161                            _ => {}
162                        }
163                    }
164                }
165            }
166
167            if last_tick.elapsed() >= tick_rate {
168                last_tick = Instant::now();
169            }
170        }
171    }
172
173    fn next(&mut self) {
174        let i = match self.state.selected() {
175            Some(i) => {
176                if i >= self.sessions.len() - 1 {
177                    0
178                } else {
179                    i + 1
180                }
181            }
182            None => 0,
183        };
184        self.state.select(Some(i));
185    }
186
187    fn previous(&mut self) {
188        let i = match self.state.selected() {
189            Some(i) => {
190                if i == 0 {
191                    self.sessions.len() - 1
192                } else {
193                    i - 1
194                }
195            }
196            None => 0,
197        };
198        self.state.select(Some(i));
199    }
200
201    fn ui(&mut self, f: &mut Frame) {
202        let chunks = Layout::default()
203            .direction(Direction::Vertical)
204            .margin(1)
205            .constraints([
206                Constraint::Length(3),
207                Constraint::Min(0),
208                Constraint::Length(3),
209            ])
210            .split(f.area());
211
212        // Header - more minimal
213        let header = Paragraph::new("SESSIONS")
214            .style(Style::default().fg(Color::DarkGray))
215            .alignment(Alignment::Left)
216            .block(
217                Block::default()
218                    .borders(Borders::BOTTOM)
219                    .border_style(Style::default().fg(Color::DarkGray)),
220            );
221        f.render_widget(header, chunks[0]);
222
223        // Sessions list
224        let items: Vec<ListItem> = self
225            .sessions
226            .iter()
227            .map(|session| {
228                let client_count = session.get_client_count();
229
230                let now = chrono::Utc::now().timestamp();
231                let created = session.created_at.timestamp();
232                let duration = now - created;
233                let uptime = format_duration(duration as u64);
234
235                // Check if this is the current attached session
236                let is_current = self.current_session_id.as_ref() == Some(&session.id);
237
238                // Status indicator - simplified
239                let (status_icon, status_color) = if is_current {
240                    ("★", Color::Cyan)
241                } else if client_count > 0 {
242                    ("●", Color::Green)
243                } else {
244                    ("○", Color::Gray)
245                };
246
247                // Session name styling
248                let name_style = if is_current {
249                    Style::default()
250                        .fg(Color::Cyan)
251                        .add_modifier(Modifier::BOLD)
252                } else {
253                    Style::default().fg(Color::White)
254                };
255
256                // Build the status text that appears on the right
257                let status_text = if is_current {
258                    if client_count > 0 {
259                        format!(
260                            "CURRENT SESSION · {} CLIENT{}",
261                            client_count,
262                            if client_count == 1 { "" } else { "S" }
263                        )
264                    } else {
265                        "CURRENT SESSION".to_string()
266                    }
267                } else if client_count > 0 {
268                    format!(
269                        "{} CLIENT{}",
270                        client_count,
271                        if client_count == 1 { "" } else { "S" }
272                    )
273                } else {
274                    "DETACHED".to_string()
275                };
276
277                // Format created time
278                let now = chrono::Local::now();
279                let local_time: chrono::DateTime<chrono::Local> = session.created_at.into();
280                let duration = now.signed_duration_since(local_time);
281
282                let created_time = if duration.num_days() > 0 {
283                    format!(
284                        "{}d, {:02}:{:02}",
285                        duration.num_days(),
286                        local_time.hour(),
287                        local_time.minute()
288                    )
289                } else {
290                    local_time.format("%H:%M:%S").to_string()
291                };
292
293                // Truncate working dir if too long
294                let mut working_dir = session.working_dir.clone();
295                if working_dir.len() > 30 {
296                    working_dir = format!(
297                        "...{}",
298                        &session.working_dir[session.working_dir.len() - 27..]
299                    );
300                }
301
302                // Build left side with fixed widths
303                let left_side = format!(
304                    " {} {:<25} │ PID {:<6} │ {:<8} │ {:<8} │ {:<30}",
305                    status_icon,
306                    session.display_name(),
307                    session.pid,
308                    uptime,
309                    created_time,
310                    working_dir
311                );
312
313                // Calculate padding for right alignment
314                let terminal_width = terminal::size().unwrap_or((80, 24)).0 as usize;
315                let left_len = left_side.chars().count();
316                let status_len = status_text.chars().count();
317                let padding = terminal_width.saturating_sub(left_len + status_len + 2);
318
319                let content = vec![Line::from(vec![
320                    Span::styled(
321                        format!(" {} ", status_icon),
322                        Style::default()
323                            .fg(status_color)
324                            .add_modifier(Modifier::BOLD),
325                    ),
326                    Span::styled(format!("{:<25}", session.display_name()), name_style),
327                    Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
328                    Span::styled(
329                        format!("PID {:<6}", session.pid),
330                        Style::default().fg(Color::DarkGray),
331                    ),
332                    Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
333                    Span::styled(
334                        format!("{:<8}", uptime),
335                        Style::default().fg(Color::DarkGray),
336                    ),
337                    Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
338                    Span::styled(
339                        format!("{:<8}", created_time),
340                        Style::default().fg(Color::DarkGray),
341                    ),
342                    Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
343                    Span::styled(
344                        format!("{:<30}", working_dir),
345                        Style::default().fg(Color::DarkGray),
346                    ),
347                    Span::styled(" ".repeat(padding), Style::default()),
348                    Span::styled(
349                        status_text.clone(),
350                        if is_current {
351                            Style::default()
352                                .fg(Color::Cyan)
353                                .add_modifier(Modifier::BOLD)
354                        } else if client_count > 0 {
355                            Style::default().fg(Color::Green)
356                        } else {
357                            Style::default()
358                                .fg(Color::DarkGray)
359                                .add_modifier(Modifier::DIM)
360                        },
361                    ),
362                ])];
363                ListItem::new(content)
364            })
365            .collect();
366
367        let sessions_list = List::new(items)
368            .block(Block::default().borders(Borders::NONE))
369            .highlight_style(
370                Style::default()
371                    .bg(Color::Rgb(40, 40, 40))
372                    .add_modifier(Modifier::BOLD),
373            )
374            .highlight_symbol("");
375
376        f.render_stateful_widget(sessions_list, chunks[1], &mut self.state);
377
378        // Footer - cleaner design
379        let help_text = vec![
380            Span::styled("↑↓/jk ", Style::default().fg(Color::DarkGray)),
381            Span::styled("navigate", Style::default().fg(Color::Gray)),
382            Span::styled("  ", Style::default()),
383            Span::styled("⏎ ", Style::default().fg(Color::DarkGray)),
384            Span::styled("attach", Style::default().fg(Color::Gray)),
385            Span::styled("  ", Style::default()),
386            Span::styled("q ", Style::default().fg(Color::DarkGray)),
387            Span::styled("quit", Style::default().fg(Color::Gray)),
388        ];
389
390        let session_info = format!("{} sessions", self.sessions.len());
391
392        let footer = Paragraph::new(Line::from(help_text))
393            .style(Style::default())
394            .alignment(Alignment::Center)
395            .block(
396                Block::default()
397                    .borders(Borders::TOP)
398                    .border_style(Style::default().fg(Color::DarkGray)),
399            );
400        f.render_widget(footer, chunks[2]);
401
402        // Session count on the right
403        let count_widget = Paragraph::new(session_info)
404            .style(Style::default().fg(Color::DarkGray))
405            .alignment(Alignment::Right);
406        let count_area = Rect {
407            x: chunks[2].x + 2,
408            y: chunks[2].y + 1,
409            width: chunks[2].width - 4,
410            height: 1,
411        };
412        f.render_widget(count_widget, count_area);
413    }
414}
415
416fn format_duration(seconds: u64) -> String {
417    if seconds < 60 {
418        format!("{}s", seconds)
419    } else if seconds < 3600 {
420        format!("{}m", seconds / 60)
421    } else if seconds < 86400 {
422        format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60)
423    } else {
424        format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600)
425    }
426}