From 6fb7b570545add90e0492176051db837c795f054 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 29 May 2026 01:04:56 +0900 Subject: [PATCH] feat: add multi pod tui dashboard --- crates/tui/src/main.rs | 109 +++++ crates/tui/src/multi_pod.rs | 851 ++++++++++++++++++++++++++++++++++++ 2 files changed, 960 insertions(+) create mode 100644 crates/tui/src/multi_pod.rs diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 16473320..4925d3a5 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4,6 +4,7 @@ mod cache; mod command; mod input; mod markdown; +mod multi_pod; mod picker; mod pod_list; mod scroll; @@ -71,6 +72,10 @@ enum Mode { /// `tui --session `: skip the picker, go straight to the /// resume name dialog with `id` baked in. ResumeWithSession(SegmentId), + /// `tui --multi`: open the multi-Pod dashboard. This is intentionally + /// separate from `-r`/`--resume`, which keeps its single-Pod picker + /// meaning. + Multi, } #[derive(Debug)] @@ -101,9 +106,11 @@ where { let args: Vec = args.into_iter().map(Into::into).collect(); let mut resume = false; + let mut multi = false; let mut session: Option = None; let mut pod: Option = None; let mut socket_override: Option = None; + let mut socket_seen = false; let mut positional: Option = None; let mut i = 0; @@ -113,6 +120,10 @@ where resume = true; i += 1; } + "--multi" => { + multi = true; + i += 1; + } "--session" => { let raw = args .get(i + 1) @@ -129,6 +140,7 @@ where i += 2; } "--socket" => { + socket_seen = true; let raw = args .get(i + 1) .ok_or(ParseError::MissingValue("--socket"))?; @@ -147,6 +159,35 @@ where } } + if multi { + if resume { + return Err(ParseError::Conflict( + "--multi and --resume are mutually exclusive", + )); + } + if session.is_some() { + return Err(ParseError::Conflict( + "--multi and --session are mutually exclusive", + )); + } + if pod.is_some() { + return Err(ParseError::Conflict( + "--multi and --pod are mutually exclusive", + )); + } + if positional.is_some() { + return Err(ParseError::Conflict( + "--multi cannot be used with a positional Pod name", + )); + } + if socket_seen { + return Err(ParseError::Conflict( + "--multi and --socket are mutually exclusive", + )); + } + return Ok(Mode::Multi); + } + if resume && session.is_some() { return Err(ParseError::Conflict( "--resume and --session are mutually exclusive", @@ -212,6 +253,7 @@ async fn main() -> ExitCode { } => run_pod_name(pod_name, socket_override).await, Mode::Resume => run_resume().await, Mode::ResumeWithSession(id) => run_spawn(Some(id)).await, + Mode::Multi => run_multi().await, }; // Always restore the terminal first so any pending eprintln below @@ -311,6 +353,25 @@ async fn run_resume() -> Result<(), Box> { run_pod_name(pod_name, socket_override).await } +async fn run_multi() -> Result<(), Box> { + let mut terminal = enter_fullscreen()?; + let outcome = multi_pod::run(&mut terminal).await; + + let _ = execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + ); + + match outcome? { + multi_pod::MultiPodOutcome::Quit => Ok(()), + multi_pod::MultiPodOutcome::Open { + pod_name, + socket_override, + } => run_pod_name(pod_name, socket_override).await, + } +} + async fn run_spawn(resume_from: Option) -> Result<(), Box> { let ready = match spawn::run(resume_from).await? { SpawnOutcome::Ready(r) => r, @@ -948,6 +1009,54 @@ mod tests { ); } + #[test] + fn parse_multi_mode() { + match parse_args_from(["--multi"]).unwrap() { + Mode::Multi => {} + _ => panic!("expected Multi mode"), + } + } + + #[test] + fn parse_multi_conflicts_are_clear() { + let segment_id = session_store::new_segment_id().to_string(); + let cases = [ + ( + vec!["--multi".to_string(), "--resume".to_string()], + "--multi and --resume are mutually exclusive", + ), + ( + vec!["--multi".to_string(), "--session".to_string(), segment_id], + "--multi and --session are mutually exclusive", + ), + ( + vec![ + "--multi".to_string(), + "--pod".to_string(), + "agent".to_string(), + ], + "--multi and --pod are mutually exclusive", + ), + ( + vec!["--multi".to_string(), "agent".to_string()], + "--multi cannot be used with a positional Pod name", + ), + ( + vec![ + "--multi".to_string(), + "--socket".to_string(), + "/tmp/a.sock".to_string(), + ], + "--multi and --socket are mutually exclusive", + ), + ]; + + for (args, message) in cases { + let err = parse_args_from(args).unwrap_err(); + assert_eq!(err.to_string(), message); + } + } + #[tokio::test] async fn terminal_event_is_selected_before_ready_pod_event() { let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs new file mode 100644 index 00000000..5c2db5a3 --- /dev/null +++ b/crates/tui/src/multi_pod.rs @@ -0,0 +1,851 @@ +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, read}; +use protocol::stream::{JsonLineReader, JsonLineWriter}; +use protocol::{ErrorCode, Event, InvokeKind, Method, PodStatus, Segment}; +use ratatui::Frame; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block as UiBlock, Borders, Padding, Paragraph, Widget}; +use session_store::FsStore; +use tokio::net::UnixStream; +use unicode_width::UnicodeWidthStr; + +use crate::input::InputBuffer; +use crate::pod_list::{ + PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos, + read_stored_pod_infos, +}; + +const MAX_ENTRIES: usize = 50; +const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug)] +pub(crate) enum MultiPodError { + Io(io::Error), + Store(session_store::StoreError), + NoPods, +} + +impl std::fmt::Display for MultiPodError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "io error: {e}"), + Self::Store(e) => write!(f, "session store error: {e}"), + Self::NoPods => write!( + f, + "no pods found — start a fresh pod with `tui` or restore one with `tui -r`" + ), + } + } +} + +impl std::error::Error for MultiPodError {} + +impl From for MultiPodError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +impl From for MultiPodError { + fn from(e: session_store::StoreError) -> Self { + Self::Store(e) + } +} + +pub(crate) enum MultiPodOutcome { + Quit, + Open { + pod_name: String, + socket_override: Option, + }, +} + +pub(crate) async fn run( + terminal: &mut Terminal>, +) -> Result { + let mut app = MultiPodApp::load(None).await?; + if app.list.entries.is_empty() { + return Err(MultiPodError::NoPods); + } + + loop { + terminal.draw(|f| draw(f, &mut app))?; + match read()? { + TermEvent::Key(key) => match app.handle_key(key) { + MultiPodAction::None => {} + MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit), + MultiPodAction::Open => { + if let Some(entry) = app.list.selected_entry() { + return Ok(MultiPodOutcome::Open { + pod_name: entry.name.clone(), + socket_override: entry.attach_socket_path().map(PathBuf::from), + }); + } + } + MultiPodAction::Refresh => app.reload().await?, + MultiPodAction::Send(request) => { + terminal.draw(|f| draw(f, &mut app))?; + let result = send_run_and_confirm(&request.socket_path, request.segments).await; + app.finish_send(result); + let _ = app.reload().await; + } + }, + TermEvent::Paste(text) => app.input.insert_paste(text), + TermEvent::Resize(_, _) => {} + _ => {} + } + } +} + +fn default_store_dir() -> Result { + manifest::paths::sessions_dir().ok_or_else(|| { + MultiPodError::Io(io::Error::new( + io::ErrorKind::NotFound, + "could not resolve sessions directory \ + (set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)", + )) + }) +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SendEligibility { + SendNow, + Disabled, +} + +#[derive(Debug)] +pub(crate) struct DirectSendRequest { + socket_path: PathBuf, + segments: Vec, +} + +pub(crate) struct MultiPodApp { + pub(crate) list: PodList, + pub(crate) input: InputBuffer, + notice: Option, + sending: bool, +} + +impl MultiPodApp { + async fn load(selected_name: Option) -> Result { + Ok(Self { + list: load_pod_list(selected_name).await?, + input: InputBuffer::new(), + notice: None, + sending: false, + }) + } + + async fn reload(&mut self) -> Result<(), MultiPodError> { + self.list = load_pod_list(self.list.selected_name.clone()).await?; + Ok(()) + } + + #[cfg(test)] + pub(crate) fn selected_send_eligibility(&self) -> SendEligibility { + match self.list.selected_entry() { + Some(entry) if entry.actions.can_send_now => SendEligibility::SendNow, + _ => SendEligibility::Disabled, + } + } + + pub(crate) fn selected_send_disabled_reason(&self) -> Option { + let entry = self.list.selected_entry()?; + if entry.actions.can_send_now { + return None; + } + Some(send_disabled_reason(entry)) + } + + pub(crate) fn select_next(&mut self) { + let selected = self.list.selected_index(); + if selected + 1 < self.list.entries.len() { + self.list.select_index(selected + 1); + } + } + + pub(crate) fn select_prev(&mut self) { + let selected = self.list.selected_index().saturating_sub(1); + self.list.select_index(selected); + } + + pub(crate) fn prepare_send(&mut self) -> Option { + let entry = match self.list.selected_entry() { + Some(entry) => entry, + None => { + self.notice = Some("No Pod is selected.".to_string()); + return None; + } + }; + if !entry.actions.can_send_now { + self.notice = Some(send_disabled_reason(entry)); + return None; + } + let Some(socket_path) = entry.attach_socket_path().map(PathBuf::from) else { + self.notice = Some("Selected Pod has no reachable socket.".to_string()); + return None; + }; + let segments = self.input.submit_segments(); + if segments_are_blank(&segments) { + self.notice = Some("Composer is empty.".to_string()); + return None; + } + self.sending = true; + self.notice = Some(format!("Sending to {}…", entry.name)); + Some(DirectSendRequest { + socket_path, + segments, + }) + } + + pub(crate) fn finish_send(&mut self, result: Result<(), DirectSendError>) { + self.sending = false; + match result { + Ok(()) => { + let target = self + .list + .selected_entry() + .map(|entry| entry.name.clone()) + .unwrap_or_else(|| "selected Pod".to_string()); + self.input.clear(); + self.notice = Some(format!("Delivered to {target}.")); + } + Err(e) => { + self.notice = Some(format!("Delivery failed; composer kept: {e}")); + } + } + } + + fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + match key.code { + KeyCode::Char('d') if ctrl => MultiPodAction::Quit, + KeyCode::Char('c') if ctrl => MultiPodAction::Quit, + KeyCode::Esc => MultiPodAction::Quit, + KeyCode::Up => { + self.select_prev(); + MultiPodAction::None + } + KeyCode::Down => { + self.select_next(); + MultiPodAction::None + } + KeyCode::Char('k') if !ctrl && !alt => { + self.select_prev(); + MultiPodAction::None + } + KeyCode::Char('j') if !ctrl && !alt => { + self.select_next(); + MultiPodAction::None + } + KeyCode::Char('o') if !ctrl && !alt => MultiPodAction::Open, + KeyCode::Char('r') if !ctrl && !alt => MultiPodAction::Refresh, + KeyCode::Enter if alt => { + self.input.insert_newline(); + MultiPodAction::None + } + KeyCode::Enter => self + .prepare_send() + .map(MultiPodAction::Send) + .unwrap_or(MultiPodAction::None), + KeyCode::Backspace => { + self.input.delete_before(); + MultiPodAction::None + } + KeyCode::Delete => { + self.input.delete_after(); + MultiPodAction::None + } + KeyCode::Left => { + self.input.move_left(); + MultiPodAction::None + } + KeyCode::Right => { + self.input.move_right(); + MultiPodAction::None + } + KeyCode::Home => { + self.input.move_home(); + MultiPodAction::None + } + KeyCode::End => { + self.input.move_end(); + MultiPodAction::None + } + KeyCode::Char(c) if !ctrl => { + self.input.insert_char(c); + MultiPodAction::None + } + _ => MultiPodAction::None, + } + } +} + +enum MultiPodAction { + None, + Quit, + Open, + Refresh, + Send(DirectSendRequest), +} + +async fn load_pod_list(selected_name: Option) -> Result { + let store_dir = default_store_dir()?; + let store = FsStore::new(&store_dir)?; + let stored = read_stored_pod_infos(&store_dir, &store)?; + let live = read_reachable_live_pod_infos(&store) + .await + .unwrap_or_default(); + Ok(PodList::from_sources( + PodVisibilitySource::ResumePicker, + stored, + live, + selected_name, + MAX_ENTRIES, + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DirectSendError { + AlreadyRunning, + Rejected { code: ErrorCode, message: String }, + Io(String), +} + +impl std::fmt::Display for DirectSendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AlreadyRunning => write!(f, "target Pod is already running"), + Self::Rejected { code, message } => { + write!(f, "target rejected run ({code:?}): {message}") + } + Self::Io(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for DirectSendError {} + +async fn send_run_and_confirm(socket: &Path, input: Vec) -> Result<(), DirectSendError> { + let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket)) + .await + .map_err(|_| DirectSendError::Io("connect timed out".into()))? + .map_err(|e| DirectSendError::Io(format!("connect: {e}")))?; + let (reader, writer) = stream.into_split(); + let mut reader = JsonLineReader::new(reader); + let mut writer = JsonLineWriter::new(writer); + + loop { + let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()) + .await + .map_err(|_| DirectSendError::Io("read initial Snapshot timed out".into()))? + .map_err(|e| DirectSendError::Io(format!("read initial Snapshot: {e}")))?; + match event { + Some(Event::Snapshot { .. }) => break, + Some(Event::Alert(_)) => continue, + Some(Event::Error { + code: ErrorCode::AlreadyRunning, + .. + }) => return Err(DirectSendError::AlreadyRunning), + Some(Event::Error { code, message }) => { + return Err(DirectSendError::Rejected { code, message }); + } + Some(_) => continue, + None => { + return Err(DirectSendError::Io( + "connection closed before initial Snapshot".into(), + )); + } + } + } + + tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Run { input })) + .await + .map_err(|_| DirectSendError::Io("write timed out".into()))? + .map_err(|e| DirectSendError::Io(format!("write: {e}")))?; + + loop { + let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()) + .await + .map_err(|_| DirectSendError::Io("read response timed out".into()))? + .map_err(|e| DirectSendError::Io(format!("read response: {e}")))?; + match event { + Some(Event::Error { + code: ErrorCode::AlreadyRunning, + .. + }) => return Err(DirectSendError::AlreadyRunning), + Some(Event::Error { code, message }) => { + return Err(DirectSendError::Rejected { code, message }); + } + Some(Event::InvokeStart { + kind: InvokeKind::UserSend, + }) + | Some(Event::UserMessage { .. }) + | Some(Event::TurnStart { .. }) => return Ok(()), + Some(_) => continue, + None => { + return Err(DirectSendError::Io( + "connection closed before response".into(), + )); + } + } + } +} + +fn segments_are_blank(segments: &[Segment]) -> bool { + segments.iter().all(|segment| match segment { + Segment::Text { content } => content.trim().is_empty(), + _ => false, + }) +} + +fn send_disabled_reason(entry: &PodListEntry) -> String { + if let Some(live) = entry.live.as_ref() { + if !live.reachable { + return "Selected live Pod is unreachable.".to_string(); + } + return match live.status { + Some(PodStatus::Running) => { + "Selected Pod is running; direct send is disabled in multi-Pod view.".to_string() + } + Some(PodStatus::Paused) => { + "Selected Pod is paused; open it explicitly to resume or start a new turn." + .to_string() + } + Some(PodStatus::Idle) => "Selected Pod is not send-eligible.".to_string(), + None => "Selected Pod did not report a live status.".to_string(), + }; + } + if entry.stored.is_some() { + return "Selected Pod is stopped; open/restore it before sending.".to_string(); + } + entry + .actions + .disabled_reason + .clone() + .unwrap_or_else(|| "Selected Pod is not send-eligible.".to_string()) +} + +fn row_status_label(entry: &PodListEntry) -> (&'static str, Style) { + if let Some(live) = entry.live.as_ref() { + if !live.reachable { + return ( + "unreachable", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ); + } + return match live.status { + Some(PodStatus::Idle) => ( + "live idle", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Some(PodStatus::Running) => ( + "live running", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Some(PodStatus::Paused) => ( + "live paused", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + None => ("live unknown", Style::default().fg(Color::DarkGray)), + }; + } + if entry + .stored + .as_ref() + .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_))) + { + return ( + "corrupt", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ); + } + ("stopped/restorable", Style::default().fg(Color::Yellow)) +} + +fn draw(frame: &mut Frame<'_>, app: &mut MultiPodApp) { + let area = frame.area(); + let input_content_width = area.width.saturating_sub(2).max(1); + let input_render = app.input.render(input_content_width); + let input_height = input_area_height(&input_render, area.height); + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(input_height), + Constraint::Length(1), + ]) + .split(area); + + draw_title(frame, chunks[0]); + draw_list(frame, app, chunks[1]); + draw_separator(frame, chunks[2]); + draw_target_status(frame, app, chunks[3]); + draw_input(frame, &input_render, chunks[4]); + draw_actionbar(frame, app, chunks[5]); +} + +fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { + let needed = render.lines.len().max(1) as u16; + let cap = (terminal_height / 3).max(1).min(10); + needed.clamp(1, cap) +} + +fn draw_title(frame: &mut Frame<'_>, area: Rect) { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + "multi-Pod dashboard", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + " Enter send to idle live Pod · o open/attach · r refresh", + Style::default().fg(Color::DarkGray), + ), + ])), + area, + ); +} + +fn draw_list(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let block = UiBlock::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + let mut lines = Vec::new(); + let selected = app.list.selected_index(); + let visible_height = inner.height as usize; + let start = if visible_height == 0 { + 0 + } else { + selected.saturating_sub(visible_height.saturating_sub(1)) + }; + for (i, entry) in app + .list + .entries + .iter() + .enumerate() + .skip(start) + .take(visible_height) + { + lines.push(row_line(entry, i == selected, inner.width)); + } + Paragraph::new(lines) + .block(block) + .render(area, frame.buffer_mut()); +} + +fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> { + let marker = if selected { "▶ " } else { " " }; + let name_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let (status, status_style) = row_status_label(entry); + let action = if entry.actions.can_send_now { + "send" + } else if entry.actions.can_open { + "open" + } else { + "disabled" + }; + let mut text = format!("{marker}{} [{status}] {action}", entry.name); + if let Some(preview) = entry.summary.preview.as_deref() { + text.push_str(" "); + text.push_str(preview); + } + let truncated = truncate_with_ellipsis(&text, width as usize); + let mut spans = Vec::new(); + let prefix = format!("{marker}{}", entry.name); + let visible_prefix = format!("{marker}{} ", entry.name); + spans.push(Span::styled(visible_prefix, name_style)); + spans.push(Span::styled(format!("[{status}]"), status_style)); + let rest = truncated + .strip_prefix(&format!("{prefix} [{status}]")) + .unwrap_or(""); + spans.push(Span::styled( + rest.to_string(), + Style::default().fg(Color::DarkGray), + )); + Line::from(spans) +} + +fn draw_separator(frame: &mut Frame<'_>, area: Rect) { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "─".repeat(area.width as usize), + Style::default().fg(Color::DarkGray), + ))), + area, + ); +} + +fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + let line = match app.list.selected_entry() { + Some(entry) => { + let (status, status_style) = row_status_label(entry); + let send_text = if entry.actions.can_send_now { + "send enabled" + } else { + "send disabled" + }; + Line::from(vec![ + Span::styled("target ", Style::default().fg(Color::DarkGray)), + Span::styled( + entry.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(format!("[{status}]"), status_style), + Span::raw(" "), + Span::styled( + send_text, + if entry.actions.can_send_now { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }, + ), + ]) + } + None => Line::from(Span::styled( + "target — none", + Style::default().fg(Color::DarkGray), + )), + }; + frame.render_widget(Paragraph::new(line), area); +} + +fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { + let mut lines: Vec> = Vec::with_capacity(render.lines.len()); + for (i, src) in render.lines.iter().enumerate() { + let prefix = if i == 0 { "> " } else { " " }; + let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))]; + spans.extend(src.spans.iter().cloned()); + lines.push(Line::from(spans)); + } + frame.render_widget(Paragraph::new(lines), area); + + let cursor_x = area.x + 2 + render.cursor_col; + let cursor_y = area.y + render.cursor_row; + if cursor_y < area.y + area.height { + frame.set_cursor_position(Position::new(cursor_x, cursor_y)); + } +} + +fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + let left = if app.sending { + "sending…".to_string() + } else if let Some(notice) = app.notice.as_deref() { + notice.to_string() + } else if let Some(reason) = app.selected_send_disabled_reason() { + reason + } else { + "idle live target: Enter sends directly without opening conversation".to_string() + }; + let right = "↑/↓ select Enter send o open r refresh Esc quit"; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + truncate_with_ellipsis(&left, area.width.saturating_sub(42) as usize), + Style::default().fg(Color::DarkGray), + ))), + area, + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + right, + Style::default().fg(Color::DarkGray), + ))) + .alignment(ratatui::layout::Alignment::Right), + area, + ); +} + +fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if s.width() <= max_width { + return s.to_string(); + } + if max_width == 1 { + return "…".to_string(); + } + let mut out = String::new(); + let mut width = 0usize; + for c in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + if width + cw > max_width - 1 { + break; + } + out.push(c); + width += cw; + } + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; + + #[test] + fn multi_selection_changes_preserve_composer_contents() { + let mut app = test_app(vec![ + live_info("alpha", PodStatus::Idle), + live_info("beta", PodStatus::Idle), + ]); + app.input.insert_str("draft message"); + let before = input_text(&app); + + app.select_next(); + + assert_eq!(input_text(&app), before); + assert_eq!(app.list.selected_entry().unwrap().name, "beta"); + } + + #[test] + fn multi_idle_live_selected_target_is_send_eligible() { + let app = test_app(vec![live_info("idle", PodStatus::Idle)]); + + assert_eq!(app.selected_send_eligibility(), SendEligibility::SendNow); + assert!(app.selected_send_disabled_reason().is_none()); + } + + #[test] + fn multi_running_paused_and_stopped_targets_are_direct_send_disabled() { + let mut app = test_app(vec![ + live_info("running", PodStatus::Running), + live_info("paused", PodStatus::Paused), + ]); + let stopped = stopped_info("stopped"); + app.list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![stopped], + vec![ + live_info_with_updated_at("running", PodStatus::Running, 30), + live_info_with_updated_at("paused", PodStatus::Paused, 20), + ], + Some("running".to_string()), + 10, + ); + + assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled); + assert!( + app.selected_send_disabled_reason() + .unwrap() + .contains("running") + ); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "paused"); + assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled); + assert!( + app.selected_send_disabled_reason() + .unwrap() + .contains("paused") + ); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "stopped"); + assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled); + assert!( + app.selected_send_disabled_reason() + .unwrap() + .contains("stopped") + ); + } + + #[test] + fn multi_delivery_failure_keeps_composer_contents() { + let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("keep me"); + let before = input_text(&app); + + app.finish_send(Err(DirectSendError::Io("boom".to_string()))); + + assert_eq!(input_text(&app), before); + assert!(app.notice.as_deref().unwrap().contains("composer kept")); + } + + #[test] + fn multi_delivery_success_clears_composer_contents() { + let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("send me"); + + app.finish_send(Ok(())); + + assert_eq!(input_text(&app), ""); + assert!(app.notice.as_deref().unwrap().contains("Delivered")); + } + + fn test_app(live: Vec) -> MultiPodApp { + MultiPodApp { + list: PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), + input: InputBuffer::new(), + notice: None, + sending: false, + } + } + + fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo { + live_info_with_updated_at(pod_name, status, 0) + } + + fn live_info_with_updated_at( + pod_name: &str, + status: PodStatus, + updated_at: u64, + ) -> LivePodInfo { + LivePodInfo { + pod_name: pod_name.to_string(), + socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")), + status: Some(status), + reachable: true, + segment_id: None, + summary: PodEntrySummary { + active_session_id: None, + active_segment_id: None, + updated_at, + preview: None, + }, + } + } + + fn stopped_info(pod_name: &str) -> StoredPodInfo { + StoredPodInfo { + pod_name: pod_name.to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at: 10, + preview: None, + } + } + + fn input_text(app: &MultiPodApp) -> String { + Segment::flatten_to_text(&app.input.submit_segments()) + } +}