use crate::app::App;
use crate::types::{CliType, Focus, InputMode, LeftTab, SidebarItem};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Clear, List, ListItem, Padding, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState,
},
};
use tui_term::widget::PseudoTerminal;
const PASTEL_CYAN: Color = Color::Rgb(150, 220, 235);
const PASTEL_YELLOW: Color = Color::Rgb(255, 255, 150);
fn display_width(s: &str) -> usize {
s.chars().map(|c| char_width(c)).sum()
}
fn char_width(c: char) -> usize {
let uw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if uw <= 1 && is_wide_emoji(c) {
2
} else {
uw
}
}
fn is_wide_emoji(c: char) -> bool {
matches!(c,
'\u{23E9}' | '\u{23F8}' | '\u{26A1}' | '\u{2705}' | '\u{274C}' | '\u{2728}' | '\u{231B}' | '\u{26D4}' | '\u{2615}' | '\u{2B50}' | '\u{26AA}' | '\u{26AB}' | '\u{2764}' | '\u{203C}' | '\u{2049}' | '\u{1F916}' | '\u{1F5A5}' | '\u{1F9F1}' | '\u{1F4AC}' | '\u{1F4CB}' | '\u{1F7E2}' | '\u{1F512}' | '\u{1F4A5}' | '\u{1F534}' )
}
impl App {
pub(crate) fn render(&mut self, frame: &mut Frame) {
let [main_area, status_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(frame.area());
let [left_area, right_area] = Layout::horizontal([
Constraint::Length(self.layout.left_panel_width),
Constraint::Fill(1),
])
.areas(main_area);
self.layout.last_sessions_area = left_area;
match self.ui.left_tab {
LeftTab::Sessions => self.render_sessions(frame, left_area),
LeftTab::Agents => self.render_agents(frame, left_area),
}
self.render_right_panel(frame, right_area);
self.render_status(frame, status_area);
if matches!(self.ui.input_mode, InputMode::ConfirmQuit) {
self.render_confirm_quit_popup(frame, frame.area());
}
}
fn render_confirm_quit_popup(&self, frame: &mut Frame, area: Rect) {
let popup_width: u16 = 44;
let popup_height: u16 = 7;
let x = area.x + area.width.saturating_sub(popup_width) / 2;
let y = area.y + area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect {
x,
y,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
};
frame.render_widget(Clear, popup_area);
let block = Block::bordered()
.title(" Quit? ")
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(PASTEL_CYAN));
let lines = vec![
Line::from(""),
Line::from(Span::styled(
"Are you sure you want to leave?",
Style::new().fg(Color::White).bold(),
))
.alignment(Alignment::Center),
Line::from(""),
Line::from(vec![
Span::styled("y", Style::new().fg(Color::Yellow).bold()),
Span::raw(": yes "),
Span::styled("n", Style::new().fg(Color::Yellow).bold()),
Span::raw(": no"),
])
.alignment(Alignment::Center),
];
let p = Paragraph::new(lines).block(block);
frame.render_widget(p, popup_area);
}
fn render_sessions(&mut self, frame: &mut Frame, area: Rect) {
let focused = self.ui.focus == Focus::Sessions && matches!(self.ui.input_mode, InputMode::Normal);
let area = Rect {
width: area.width.saturating_sub(2),
..area
};
let inner_width = area.width.saturating_sub(4) as usize;
let indented: Vec<bool> = {
let mut under_label = false;
self.sidebar_items.iter().map(|item| match item {
SidebarItem::Label(_) => { under_label = true; false }
SidebarItem::Session(_) => under_label,
}).collect()
};
const INDENT: usize = 2;
let drag_info = self.drag.dragging_session.as_ref().map(|ds| (ds.from_index, ds.target_index));
let items: Vec<ListItem> = self
.sidebar_items
.iter()
.enumerate()
.map(|(sidebar_idx, item)| match item {
SidebarItem::Label(label_id) => {
let label_name = self
.labels
.get(label_id)
.map(|s| s.as_str())
.unwrap_or("?");
let name_width = display_width(label_name);
let total_pad = inner_width.saturating_sub(name_width + 2); let left_pad = total_pad / 2;
let right_pad = total_pad - left_pad;
let line = Line::from(vec![
Span::styled(
"─".repeat(left_pad),
Style::new().fg(Color::DarkGray),
),
Span::styled(
format!(" {} ", label_name),
Style::new().fg(Color::White).bold(),
),
Span::styled(
"─".repeat(right_pad),
Style::new().fg(Color::DarkGray),
),
]);
ListItem::new(vec![line])
}
SidebarItem::Session(session_id) => {
let Some(&idx) = self.session_id_map.get(session_id) else {
return ListItem::new(vec![Line::from("?")]);
};
let s: &crate::session::Session = &self.sessions[idx];
let state = s.inferred_state();
let is_indented = indented[sidebar_idx];
let effective_width = if is_indented { inner_width.saturating_sub(INDENT) } else { inner_width };
let state_emoji = state.label().to_string();
let name_style = Style::default();
let mode_emoji = s.permission_mode.emoji();
let left_prefix_width = if mode_emoji.is_empty() { 0 } else { display_width(mode_emoji) + 1 };
let display_name: String = format!("{} {}", s.cli_type.emoji(), s.name);
let ai_label = Some(format!("{} {}", state.text_label(), state_emoji));
let ai_color = state.color();
let metadata_text = {
let mode_label = s.permission_mode.label();
mode_label.to_string()
};
let ai_label_width = ai_label.as_ref().map(|s| display_width(s.as_str())).unwrap_or(0);
let right_width = match (&ai_label, metadata_text.is_empty()) {
(Some(_), false) => ai_label_width + 1 + metadata_text.len(),
(Some(_), true) => ai_label_width,
(None, false) => metadata_text.len(),
(None, true) => 0,
};
let name_max = effective_width
.saturating_sub(left_prefix_width)
.saturating_sub(if right_width == 0 { 0 } else { right_width + 1 });
let display_name: String = {
let mut w = 0;
display_name.chars().take_while(|c| {
w += char_width(*c);
w <= name_max
}).collect()
};
let used = left_prefix_width + display_width(display_name.as_str()) + right_width;
let pad1 = effective_width.saturating_sub(used).saturating_sub(1);
let mut line1_spans = vec![];
if is_indented {
line1_spans.push(Span::raw(" ".repeat(INDENT)));
}
if !mode_emoji.is_empty() {
line1_spans.push(Span::raw(format!("{} ", mode_emoji)));
}
line1_spans.extend([
Span::styled(display_name, name_style),
Span::raw(" ".repeat(pad1)),
]);
if let Some(ref ai) = ai_label {
line1_spans.push(Span::styled(
ai.clone(),
Style::new().fg(ai_color).bold(),
));
if !metadata_text.is_empty() {
line1_spans.push(Span::styled(
format!(" {}", metadata_text),
Style::new().fg(Color::DarkGray),
));
}
} else if !metadata_text.is_empty() {
line1_spans.push(Span::styled(
metadata_text,
Style::new().fg(Color::DarkGray),
));
}
let line1 = Line::from(line1_spans);
let mut item = ListItem::new(vec![line1]);
if let Some((from, _to)) = drag_info {
if sidebar_idx == from {
item = item.style(Style::new().fg(Color::DarkGray));
}
}
item
}
})
.collect();
let border_style = if focused {
Style::new().fg(PASTEL_CYAN)
} else {
Style::new().fg(PASTEL_YELLOW)
};
let list = List::new(items)
.block(
Block::bordered()
.title(" Sessions ")
.border_type(BorderType::Rounded)
.border_style(border_style),
)
.highlight_style(Style::new().bg(PASTEL_CYAN).fg(Color::Black).bold())
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut self.list_state);
let inner_height = area.height.saturating_sub(2) as usize;
let item_count = self.sidebar_items.len();
if item_count > inner_height && inner_height > 0 {
let inner = area.inner(ratatui::layout::Margin::new(1, 1));
let mut scrollbar_state = ScrollbarState::new(item_count)
.position(self.list_state.offset());
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::new().fg(Color::DarkGray))
.track_style(Style::new().fg(Color::Rgb(40, 40, 40)));
frame.render_stateful_widget(scrollbar, inner, &mut scrollbar_state);
}
}
fn render_agents(&mut self, frame: &mut Frame, area: Rect) {
let focused = self.ui.focus == Focus::Sessions && matches!(self.ui.input_mode, InputMode::Normal);
let items: Vec<ListItem> = self
.agents
.iter()
.map(|agent| ListItem::new(Line::from(Span::raw(&agent.name))))
.collect();
let border_style = if focused {
Style::new().fg(PASTEL_CYAN)
} else {
Style::new().fg(PASTEL_YELLOW)
};
let list = List::new(items)
.block(
Block::bordered()
.title(" Agents ")
.border_type(BorderType::Rounded)
.border_style(border_style)
.padding(Padding::right(2)),
)
.highlight_style(Style::new().bg(PASTEL_CYAN).fg(Color::Black).bold())
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut self.agent_list_state);
}
fn resize_all_sessions(&mut self, rows: u16, cols: u16) {
for session in &mut self.sessions {
session.resize(rows, cols);
}
}
fn render_right_panel(&mut self, frame: &mut Frame, area: Rect) {
self.layout.last_right_panel_area = area;
if self.ui.left_tab == LeftTab::Agents {
self.render_agent_content(frame, area);
return;
}
let Some(idx) = self.selected_real_index() else {
let p = Paragraph::new("Press 'n' to start a new agent session")
.block(
Block::bordered()
.title(" Output ")
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(PASTEL_YELLOW)),
)
.style(Style::new().dark_gray());
frame.render_widget(p, area);
return;
};
let border_style = if self.ui.focus == Focus::Terminal {
Style::new().fg(PASTEL_CYAN)
} else {
Style::new().fg(PASTEL_YELLOW)
};
let scroll_offset = self.sessions[idx].scroll_offset;
let max_title = area.width.saturating_sub(4) as usize;
let mut title_text = if let Some(cs) = &self.sessions[idx].claude_status {
let model = if cs.model.display_name.is_empty() {
"?"
} else {
&cs.model.display_name
};
let turn = format!("T:{}", self.sessions[idx].turn_count);
let lines_info = if cs.cost.total_lines_added > 0 || cs.cost.total_lines_removed > 0 {
format!(
"+{}/-{}",
cs.cost.total_lines_added, cs.cost.total_lines_removed
)
} else {
String::new()
};
let cost = format!("${:.2}", cs.cost.total_cost_usd);
let ctx = format!("{}%ctx", cs.context_window.used_percentage as u32);
let mode_label = self.sessions[idx].permission_mode.label();
let mut parts = vec![self.sessions[idx].name.clone(), model.to_string()];
if !mode_label.is_empty() {
parts.push(mode_label.to_string());
}
parts.push(turn);
if !lines_info.is_empty() {
parts.push(lines_info);
}
parts.push(cost);
parts.push(ctx);
format!(" {} ", parts.join(" | "))
} else {
format!(" {} ", self.sessions[idx].name)
};
if scroll_offset > 0 {
title_text.push_str(&format!("[+{}] ", scroll_offset));
}
let title = if title_text.len() > max_title {
format!("{}...", &title_text[..max_title.saturating_sub(3)])
} else {
title_text
};
let block = Block::bordered()
.title(title)
.border_type(BorderType::Rounded)
.border_style(border_style);
let inner = block.inner(area);
self.layout.last_right_panel_inner = inner;
frame.render_widget(block, area);
let pty_area = Rect {
width: inner.width.saturating_sub(1),
..inner
};
let new_size = (pty_area.height, pty_area.width);
if new_size != self.layout.last_right_panel_size && pty_area.width > 0 && pty_area.height > 0 {
self.layout.last_right_panel_size = new_size;
self.resize_all_sessions(pty_area.height, pty_area.width);
}
self.sessions[idx].set_scrollback(scroll_offset);
let mut pseudo_term = PseudoTerminal::new(self.sessions[idx].screen());
if scroll_offset > 0 {
pseudo_term = pseudo_term.cursor(tui_term::widget::Cursor::default().visibility(false));
}
frame.render_widget(pseudo_term, pty_area);
self.sessions[idx].set_scrollback(0);
let max_scroll = self.sessions[idx].max_scrollback();
if max_scroll > 0 {
let mut scrollbar_state =
ScrollbarState::new(max_scroll).position(max_scroll.saturating_sub(scroll_offset));
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::new().fg(Color::DarkGray))
.track_style(Style::new().fg(Color::Rgb(40, 40, 40)));
frame.render_stateful_widget(scrollbar, inner, &mut scrollbar_state);
}
if let Some(sel) = &self.drag.selection {
let (start_row, start_col, end_row, end_col) = sel.ordered();
let buf = frame.buffer_mut();
for vt_row in start_row..=end_row {
let screen_y = inner.y + vt_row;
if screen_y >= inner.y + inner.height {
break;
}
let col_start = if vt_row == start_row { start_col } else { 0 };
let col_end = if vt_row == end_row {
end_col
} else {
inner.width.saturating_sub(1)
};
for vt_col in col_start..=col_end {
let screen_x = inner.x + vt_col;
if screen_x >= inner.x + inner.width {
break;
}
let cell = &mut buf[(screen_x, screen_y)];
cell.set_bg(Color::Rgb(50, 50, 150));
if cell.fg == Color::Reset {
cell.set_fg(Color::White);
}
}
}
}
}
fn render_agent_content(&mut self, frame: &mut Frame, area: Rect) {
let Some(sel) = self.agent_list_state.selected() else {
let p = Paragraph::new("No agents found in agents/ directory")
.block(
Block::bordered()
.title(" Agent ")
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(PASTEL_YELLOW)),
)
.style(Style::new().dark_gray());
frame.render_widget(p, area);
return;
};
let agent = &self.agents[sel];
let title = format!(" {} ", agent.name);
let block = Block::bordered()
.title(title)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(PASTEL_CYAN));
let inner = block.inner(area);
let line_count = agent.content.lines().count() as u16;
let max_scroll = line_count.saturating_sub(inner.height);
if self.agent_scroll_offset > max_scroll {
self.agent_scroll_offset = max_scroll;
}
let p = Paragraph::new(agent.content.as_str())
.scroll((self.agent_scroll_offset, 0))
.block(block);
frame.render_widget(p, area);
}
fn render_status(&self, frame: &mut Frame, area: Rect) {
let (title, border_style, content) = match &self.ui.input_mode {
InputMode::NamingSession => (
" Session name (Esc to cancel) ".to_string(),
Style::new().fg(PASTEL_CYAN),
Line::from(vec![
Span::styled(" > ", Style::new().fg(PASTEL_CYAN).bold()),
Span::raw(&self.ui.input_buffer),
]),
),
InputMode::RenamingSession => (
" Rename session (Esc to cancel) ".to_string(),
Style::new().fg(PASTEL_CYAN),
Line::from(vec![
Span::styled(" > ", Style::new().fg(PASTEL_CYAN).bold()),
Span::raw(&self.ui.input_buffer),
]),
),
InputMode::NamingLabel => (
" Label name (Esc to cancel) ".to_string(),
Style::new().fg(PASTEL_CYAN),
Line::from(vec![
Span::styled(" > ", Style::new().fg(PASTEL_CYAN).bold()),
Span::raw(&self.ui.input_buffer),
]),
),
InputMode::SelectingSessionType => {
let style_for = |t: CliType| {
if self.ui.selected_cli_type == t {
Style::new().bg(PASTEL_CYAN).fg(Color::Black).bold()
} else {
Style::default()
}
};
(
" Select type (Esc to cancel) ".to_string(),
Style::new().fg(PASTEL_CYAN),
Line::from(vec![
Span::raw(" "),
Span::styled(" 1: 🤖 claude ", style_for(CliType::Claude)),
Span::raw(" "),
Span::styled(" 2: 🤖💥 claude danger-accept-permissions ", style_for(CliType::ClaudeDangerous)),
Span::raw(" "),
Span::styled(" 3: ⚡ amp ", style_for(CliType::Amp)),
Span::raw(" "),
Span::styled(" 4: 🖥️ console ", style_for(CliType::Console)),
Span::styled(
" ←/→: switch Enter: confirm",
Style::new().dark_gray(),
),
]),
)
}
_ => {
let show_copied = self
.ui
.copied_at
.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2));
let content = if show_copied {
Line::from(Span::styled(
" Copied to clipboard!",
Style::new().fg(Color::Green).bold(),
))
} else {
Line::from(vec![
Span::raw(" Shift("),
Span::styled("⇧", Style::new().fg(Color::Yellow).bold()),
Span::raw(") + "),
Span::styled("←/→", Style::new().fg(Color::Yellow).bold()),
Span::raw(": panel switch "),
Span::styled("←/→", Style::new().fg(Color::Yellow).bold()),
Span::raw(": tab "),
Span::styled("n", Style::new().fg(Color::Yellow).bold()),
Span::raw(": new "),
Span::styled("e", Style::new().fg(Color::Yellow).bold()),
Span::raw(": rename "),
Span::styled("g", Style::new().fg(Color::Yellow).bold()),
Span::raw(": label "),
Span::styled("r", Style::new().fg(Color::Yellow).bold()),
Span::raw(": remove "),
Span::styled("q", Style::new().fg(Color::Yellow).bold()),
Span::raw(": quit "),
Span::styled("Control(⌃) + n : global new", Style::new().fg(Color::DarkGray)),
])
};
(
" neimar ".to_string(),
Style::new().fg(PASTEL_YELLOW),
content,
)
}
};
let p = Paragraph::new(content).block(
Block::bordered()
.title(title)
.border_type(BorderType::Rounded)
.border_style(border_style),
);
frame.render_widget(p, area);
if matches!(
self.ui.input_mode,
InputMode::NamingSession
| InputMode::RenamingSession
| InputMode::NamingLabel
) {
let x = area.x + 4 + self.ui.input_buffer.len() as u16;
let y = area.y + 1;
frame.set_cursor_position(Position::new(x, y));
}
}
}