use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
};
use super::client::SessionRow;
pub const KEY_HINT: &str =
"keys: ↵ send | s sidebar | Tab focus | ↑↓ scroll/select | ? help | q quit";
pub const COMMAND_HISTORY_LIMIT: usize = 20;
#[derive(Debug, Clone, Default)]
pub struct CommandBar {
pub input: String,
pub history: Vec<String>,
history_cursor: Option<usize>,
}
impl CommandBar {
pub fn push(&mut self, c: char) {
self.input.push(c);
self.history_cursor = None;
}
pub fn backspace(&mut self) {
self.input.pop();
self.history_cursor = None;
}
pub fn clear(&mut self) {
self.input.clear();
self.history_cursor = None;
}
pub fn history_prev(&mut self) {
if self.history.is_empty() {
return;
}
let next = match self.history_cursor {
None => self.history.len() - 1,
Some(0) => 0,
Some(i) => i - 1,
};
self.history_cursor = Some(next);
self.input = self.history[next].clone();
}
pub fn history_next(&mut self) {
let Some(i) = self.history_cursor else {
return;
};
if i + 1 >= self.history.len() {
self.history_cursor = None;
self.input.clear();
} else {
self.history_cursor = Some(i + 1);
self.input = self.history[i + 1].clone();
}
}
pub fn take_for_execution(&mut self) -> String {
let typed = std::mem::take(&mut self.input);
self.history_cursor = None;
let trimmed = typed.trim().to_string();
if !trimmed.is_empty() {
self.history.push(trimmed.clone());
if self.history.len() > COMMAND_HISTORY_LIMIT {
let overflow = self.history.len() - COMMAND_HISTORY_LIMIT;
self.history.drain(0..overflow);
}
}
trimmed
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatRole {
User,
Coordinator,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: ChatRole,
pub content: String,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
role: ChatRole::User,
content: content.into(),
}
}
pub fn coordinator(content: impl Into<String>) -> Self {
Self {
role: ChatRole::Coordinator,
content: content.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Focus {
#[default]
Input,
Sidebar,
}
#[derive(Debug, Clone, Default)]
pub struct DashboardState {
pub sessions: Vec<SessionRow>,
pub daemon_reachable: bool,
pub chat_history: Vec<ChatMessage>,
pub sidebar_visible: bool,
pub chat_scroll: usize,
pub focus: Focus,
pub selected_session: usize,
pub last_action: Option<String>,
pub show_help: bool,
pub command_bar: CommandBar,
pub coord_history: Vec<crate::client::ChatMessage>,
pub should_exit: bool,
}
impl DashboardState {
pub fn clamp_selection(&mut self) {
let max = self.sessions.len().saturating_sub(1);
if self.selected_session > max {
self.selected_session = max;
}
}
pub fn select_up(&mut self) {
self.selected_session = self.selected_session.saturating_sub(1);
self.clamp_selection();
}
pub fn select_down(&mut self) {
let max = self.sessions.len().saturating_sub(1);
if self.selected_session < max {
self.selected_session += 1;
}
}
pub fn selected_target(&self) -> Option<String> {
self.sessions
.get(self.selected_session)
.map(|s| s.tmux_name.clone())
}
pub fn toggle_sidebar(&mut self) {
self.sidebar_visible = !self.sidebar_visible;
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Focus::Input if self.sidebar_visible => Focus::Sidebar,
_ => Focus::Input,
};
}
pub fn push_chat(&mut self, msg: ChatMessage) {
self.chat_history.push(msg);
self.chat_scroll = usize::MAX;
}
pub fn scroll_up(&mut self) {
self.chat_scroll = self.chat_scroll.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.chat_scroll = self.chat_scroll.saturating_add(1);
}
}
pub fn status_indicator(status: crate::core::session::SessionStatus) -> (char, Color) {
use crate::core::session::SessionStatus;
match status {
SessionStatus::Active | SessionStatus::Starting => ('●', Color::Green),
SessionStatus::AwaitingApproval | SessionStatus::Paused | SessionStatus::Detached => {
('○', Color::Yellow)
}
SessionStatus::Stopped => ('✕', Color::Red),
}
}
pub fn session_prefix(name: &str) -> &str {
name.strip_prefix("tmpm-").unwrap_or(name)
}
pub fn sidebar_items(state: &DashboardState) -> Vec<ListItem<'static>> {
state
.sessions
.iter()
.enumerate()
.map(|(idx, s)| {
let (glyph, color) = status_indicator(s.status);
let name = if s.tmux_name.is_empty() {
short_session(&s.id)
} else {
s.tmux_name.clone()
};
let line = Line::from(vec![
Span::styled(format!("{glyph} "), Style::default().fg(color)),
Span::raw(name),
]);
let item = ListItem::new(line);
if idx == state.selected_session && state.focus == Focus::Sidebar {
item.style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
} else {
item
}
})
.collect()
}
pub(crate) fn short_session(id: &crate::core::session::SessionId) -> String {
id.0.to_string().chars().take(8).collect()
}
pub fn chat_lines(state: &DashboardState) -> Vec<(String, ChatRole)> {
if state.chat_history.is_empty() {
return vec![(
"(no messages yet — type a question, or @session: to route a command)".to_string(),
ChatRole::Coordinator,
)];
}
let mut lines = Vec::new();
for msg in &state.chat_history {
let prefix = match msg.role {
ChatRole::User => "[user] ",
ChatRole::Coordinator => "[coord] ",
};
for (i, raw) in msg.content.lines().enumerate() {
let text = if i == 0 {
format!("{prefix}{raw}")
} else {
format!(" {raw}")
};
lines.push((text, msg.role));
}
}
lines
}
pub fn clamp_scroll(scroll: usize, total: usize, height: usize) -> usize {
let max = total.saturating_sub(height);
scroll.min(max)
}
fn title_style(daemon_reachable: bool) -> Style {
let base = Style::default()
.fg(if daemon_reachable {
Color::Cyan
} else {
Color::Red
})
.add_modifier(Modifier::BOLD);
if daemon_reachable {
base
} else {
base.add_modifier(Modifier::REVERSED)
}
}
pub fn status_line(state: &DashboardState) -> String {
state
.last_action
.clone()
.unwrap_or_else(|| KEY_HINT.to_string())
}
fn panel_title(text: impl Into<String>) -> Line<'static> {
Line::from(Span::styled(
text.into(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
pub fn help_text() -> String {
[
" Enter send the typed message to the coordinator",
" s toggle the session sidebar",
" Tab switch focus: input bar ↔ session sidebar",
" ↑ / ↓ scroll chat (input focus) / select session (sidebar)",
" ? toggle this help",
" Esc clear input / close help",
" q quit",
"",
" Prefix a message with @session: to route a command directly.",
]
.join("\n")
}
fn render_help_overlay(frame: &mut Frame) {
let area = centered_rect(58, 13, frame.area());
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(help_text())
.style(Style::default().fg(Color::Reset))
.block(
Block::default()
.borders(Borders::ALL)
.title(panel_title("Help — press ? or Esc to close")),
),
area,
);
}
pub fn command_input_line(bar: &CommandBar, focused: bool) -> String {
if focused {
format!("CMD> {}_", bar.input)
} else {
format!("CMD> {}", bar.input)
}
}
const SIDEBAR_WIDTH: u16 = 22;
pub fn render(frame: &mut Frame, state: &DashboardState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(4), Constraint::Length(3), ])
.split(frame.area());
let title = if state.daemon_reachable {
format!("trusty-mpm — {} session(s)", state.sessions.len())
} else {
"trusty-mpm — daemon unreachable".to_string()
};
let header = Paragraph::new(vec![
Line::from(title).style(title_style(state.daemon_reachable)),
Line::from(status_line(state)).style(
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED),
),
]);
frame.render_widget(header, chunks[0]);
let chat_area = if state.sidebar_visible {
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(20)])
.split(chunks[1]);
let sidebar = List::new(sidebar_items(state)).block(
Block::default()
.borders(Borders::ALL)
.title(panel_title("Sessions")),
);
frame.render_widget(sidebar, middle[0]);
middle[1]
} else {
chunks[1]
};
let lines = chat_lines(state);
let height = chat_area.height.saturating_sub(2) as usize;
let offset = clamp_scroll(state.chat_scroll, lines.len(), height);
let items: Vec<ListItem> = lines
.into_iter()
.skip(offset)
.map(|(text, role)| {
let color = match role {
ChatRole::User => Color::Reset,
ChatRole::Coordinator => Color::Cyan,
};
ListItem::new(Line::from(text).style(Style::default().fg(color)))
})
.collect();
let chat = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(panel_title("Coordinator Chat")),
);
frame.render_widget(chat, chat_area);
let input_focused = state.focus == Focus::Input;
let input_style = if input_focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let input = Paragraph::new(command_input_line(&state.command_bar, input_focused))
.style(input_style)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(input, chunks[2]);
if state.show_help {
render_help_overlay(frame);
}
}
#[cfg(test)]
mod tests;