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 let mut current_session_id = std::env::var("NDS_SESSION_ID").ok();
41
42 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 let mut ppid = std::process::id();
57
58 for _ in 0..10 {
60 let ppid_result = Self::get_parent_pid(ppid as i32);
62 if let Some(parent_pid) = ppid_result {
63 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 #[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 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 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 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 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 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 let is_current = self.current_session_id.as_ref() == Some(&session.id);
237
238 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 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 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 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 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 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 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 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 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}