use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, List, ListItem, ListState},
Frame,
};
use unicode_width::UnicodeWidthStr;
use crate::ui::SplitDirection;
use tmai_core::agents::{AgentMode, AgentStatus, MonitoredAgent};
use tmai_core::auto_approve::AutoApprovePhase;
use tmai_core::state::{AppState, SortBy};
#[derive(Debug, Clone, Default)]
pub struct GroupTaskSummary {
pub done: usize,
pub total: usize,
}
#[derive(Debug, Clone)]
pub enum ListEntry {
Agent(usize), GroupHeader {
key: String,
agent_count: usize,
attention_count: usize,
collapsed: bool,
task_summary: Option<GroupTaskSummary>,
worktree_count: Option<usize>,
},
CreateNew {
group_key: String,
},
}
pub struct SessionList;
impl SessionList {
pub fn render(
frame: &mut Frame,
area: Rect,
state: &AppState,
split_direction: SplitDirection,
) {
match split_direction {
SplitDirection::Horizontal => Self::render_vertical_list(frame, area, state),
SplitDirection::Vertical => Self::render_horizontal_list(frame, area, state),
}
}
fn render_vertical_list(frame: &mut Frame, area: Rect, state: &AppState) {
let spinner_char = state.spinner_char();
let marquee_offset = state.marquee_offset();
let show_activity_name = state.show_activity_name;
let (entries, ui_entry_index, _selectable_count, _agent_index) = Self::build_entries(state);
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let is_selected = idx == ui_entry_index;
match entry {
ListEntry::Agent(agent_idx) => {
if let Some(agent) = state
.agent_order
.get(*agent_idx)
.and_then(|id| state.agents.get(id))
{
let tree_prefix = Self::get_tree_prefix(agent, state, *agent_idx);
let agent_def_desc = if agent.status == AgentStatus::Offline {
state
.agent_definitions
.iter()
.find(|d| {
agent
.team_info
.as_ref()
.is_some_and(|ti| ti.member_name == d.name)
})
.and_then(|d| d.description.as_deref())
} else {
None
};
Self::create_list_item(
agent,
spinner_char,
is_selected,
marquee_offset,
&tree_prefix,
show_activity_name,
agent_def_desc,
)
} else {
ListItem::new(Line::from(""))
}
}
ListEntry::GroupHeader {
key,
agent_count,
attention_count,
collapsed,
task_summary,
worktree_count,
} => Self::create_group_header(
key,
*agent_count,
*attention_count,
*collapsed,
is_selected,
marquee_offset,
task_summary.as_ref(),
*worktree_count,
),
ListEntry::CreateNew { .. } => Self::create_new_item(),
}
})
.collect();
let title = format!(
" Agents ({}) {} ",
state.agents.len(),
if state.attention_count() > 0 {
format!("[{}!]", state.attention_count())
} else {
String::new()
}
);
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Gray)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{25B6} ");
let mut list_state = ListState::default();
list_state.select(Some(ui_entry_index));
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_horizontal_list(frame: &mut Frame, area: Rect, state: &AppState) {
let spinner_char = state.spinner_char();
let marquee_offset = state.marquee_offset();
let show_activity_name = state.show_activity_name;
let (entries, ui_entry_index, _selectable_count, _agent_index) = Self::build_entries(state);
let inner_width = area.width.saturating_sub(4);
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let is_selected = idx == ui_entry_index;
match entry {
ListEntry::Agent(agent_idx) => {
if let Some(agent) = state
.agent_order
.get(*agent_idx)
.and_then(|id| state.agents.get(id))
{
let tree_prefix = Self::get_tree_prefix(agent, state, *agent_idx);
Self::create_compact_item(
agent,
spinner_char,
inner_width,
is_selected,
marquee_offset,
&tree_prefix,
show_activity_name,
)
} else {
ListItem::new(Line::from(""))
}
}
ListEntry::GroupHeader {
key,
agent_count,
attention_count,
collapsed,
task_summary,
worktree_count,
} => Self::create_compact_group_header(
key,
inner_width,
*agent_count,
*attention_count,
*collapsed,
is_selected,
marquee_offset,
task_summary.as_ref(),
*worktree_count,
),
ListEntry::CreateNew { .. } => {
Self::create_compact_new_item(inner_width, is_selected)
}
}
})
.collect();
let title = format!(
" Agents ({}) {} ",
state.agents.len(),
if state.attention_count() > 0 {
format!("[{}!]", state.attention_count())
} else {
String::new()
}
);
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Gray)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut list_state = ListState::default();
list_state.select(Some(ui_entry_index));
frame.render_stateful_widget(list, area, &mut list_state);
}
pub fn build_entries(state: &AppState) -> (Vec<ListEntry>, usize, usize, Option<usize>) {
let mut entries = Vec::new();
let mut selectable_index = 0; let mut ui_entry_index = 0; let mut selected_agent_index: Option<usize> = None;
let mut group_stats: std::collections::HashMap<String, (usize, usize, usize)> =
std::collections::HashMap::new();
for id in &state.agent_order {
if let Some(agent) = state.agents.get(id) {
if let Some(group_key) = state.get_group_key(agent) {
let entry = group_stats.entry(group_key).or_insert((0, 0, 0));
entry.0 += 1; if agent.status.needs_attention() {
entry.1 += 1; }
if agent.is_worktree.unwrap_or(false) {
entry.2 += 1; }
}
}
}
let mut current_group: Option<String> = None;
for (agent_idx, id) in state.agent_order.iter().enumerate() {
if let Some(agent) = state.agents.get(id) {
let is_nested_member = agent.team_info.as_ref().is_some_and(|ti| !ti.is_lead);
if let Some(group_key) = state.get_group_key(agent) {
if current_group.as_ref() != Some(&group_key) && !is_nested_member {
let collapsed = state.is_group_collapsed(&group_key);
let (agent_count, attention_count, wt_count) =
group_stats.get(&group_key).copied().unwrap_or((0, 0, 0));
if selectable_index == state.selection.selected_entry_index {
ui_entry_index = entries.len();
}
let task_summary = if state.sort_by == SortBy::Team {
let team_name = group_key.strip_prefix("Team: ");
team_name.and_then(|name| {
state.teams.get(name).map(|snapshot| GroupTaskSummary {
done: snapshot.task_done,
total: snapshot.task_total,
})
})
} else {
None
};
let worktree_count = if state.sort_by == SortBy::Repository && wt_count > 0
{
Some(wt_count)
} else {
None
};
entries.push(ListEntry::GroupHeader {
key: group_key.clone(),
agent_count,
attention_count,
collapsed,
task_summary,
worktree_count,
});
selectable_index += 1; current_group = Some(group_key.clone());
if collapsed {
continue;
}
} else if state.is_group_collapsed(&group_key) {
continue;
}
}
if selectable_index == state.selection.selected_entry_index {
ui_entry_index = entries.len();
selected_agent_index = Some(agent_idx);
}
entries.push(ListEntry::Agent(agent_idx));
selectable_index += 1;
}
}
if selectable_index == state.selection.selected_entry_index {
ui_entry_index = entries.len();
}
entries.push(ListEntry::CreateNew {
group_key: String::new(),
});
selectable_index += 1;
(
entries,
ui_entry_index,
selectable_index,
selected_agent_index,
)
}
pub fn get_selected_entry(state: &AppState) -> Option<ListEntry> {
let (entries, ui_entry_index, _, _) = Self::build_entries(state);
entries.get(ui_entry_index).cloned()
}
#[allow(clippy::too_many_arguments)]
fn create_group_header(
header: &str,
agent_count: usize,
attention_count: usize,
collapsed: bool,
is_selected: bool,
marquee_offset: usize,
task_summary: Option<&GroupTaskSummary>,
worktree_count: Option<usize>,
) -> ListItem<'static> {
let icon = if collapsed { "\u{25B8}" } else { "\u{25BE}" };
const HEADER_MAX_WIDTH: usize = 40;
let display = get_marquee_text_path(header, HEADER_MAX_WIDTH, marquee_offset, is_selected);
let mut spans = vec![Span::styled(
format!("{} {} ", icon, display.trim_end()),
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
)];
let count_text = match worktree_count {
Some(wt) if wt > 0 => format!("({}, {} WT)", agent_count, wt),
_ => format!("({})", agent_count),
};
spans.push(Span::styled(
count_text,
Style::default().fg(Color::DarkGray),
));
if attention_count > 0 {
spans.push(Span::styled(
format!(" \u{26A0}{}", attention_count),
Style::default().fg(Color::Red),
));
}
if let Some(summary) = task_summary {
if summary.total > 0 {
spans.push(Span::styled(
format!(" Tasks: {}/{}", summary.done, summary.total),
Style::default().fg(Color::Yellow),
));
}
}
ListItem::new(Line::from(spans))
}
fn create_new_item() -> ListItem<'static> {
ListItem::new(Line::from(vec![Span::styled(
"+ New Process",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::ITALIC),
)]))
}
fn create_list_item(
agent: &MonitoredAgent,
spinner_char: char,
is_selected: bool,
marquee_offset: usize,
tree_prefix: &str,
show_activity_name: bool,
agent_def_description: Option<&str>,
) -> ListItem<'static> {
let (status_indicator, status_color) = match (&agent.status, &agent.auto_approve_phase) {
(AgentStatus::AwaitingApproval { .. }, Some(AutoApprovePhase::Judging)) => {
("\u{27F3}".to_string(), Color::Cyan) }
(
AgentStatus::AwaitingApproval { .. },
Some(AutoApprovePhase::ApprovedByRule | AutoApprovePhase::ApprovedByAi),
) => ("\u{2713}".to_string(), Color::Green), (AgentStatus::Processing { .. }, _) => {
(spinner_char.to_string(), Self::status_color(&agent.status))
}
_ => (
agent.status.indicator().to_string(),
Self::status_color(&agent.status),
),
};
let detection_color = match agent.detection_source {
tmai_core::agents::DetectionSource::HttpHook => Color::Cyan,
tmai_core::agents::DetectionSource::IpcSocket => Color::Green,
tmai_core::agents::DetectionSource::WebSocket => Color::Cyan,
tmai_core::agents::DetectionSource::CapturePane => Color::DarkGray,
};
let mut line1_spans: Vec<Span<'static>> = Vec::new();
if !tree_prefix.is_empty() {
line1_spans.push(Span::styled(
format!("{} ", tree_prefix),
Style::default().fg(Color::DarkGray),
));
}
line1_spans.extend([
Span::styled(
format!("{} ", status_indicator),
Style::default().fg(status_color),
),
Span::styled(
agent.agent_type.short_name().to_string(),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("[{}]", agent.detection_source.label()),
Style::default().fg(detection_color),
),
]);
let send_color = if agent.send_capability.can_send() {
Color::DarkGray
} else {
Color::Red
};
line1_spans.push(Span::styled(
format!("{}", agent.send_capability.icon()),
Style::default().fg(send_color),
));
if let Some(ref team_info) = agent.team_info {
line1_spans.push(Span::styled(
format!(" [{}/{}]", team_info.team_name, team_info.member_name),
Style::default().fg(Color::Magenta),
));
}
if let Some(percent) = agent.context_warning {
let warning_color = if percent <= 10 {
Color::Red
} else if percent <= 20 {
Color::Yellow
} else {
Color::Rgb(255, 165, 0) };
line1_spans.push(Span::styled(
format!(" \u{26A0}{}%", percent),
Style::default().fg(warning_color),
));
}
if agent.compaction_count > 0 {
line1_spans.push(Span::styled(
format!(" \u{267B}\u{FE0E}{}x", agent.compaction_count),
Style::default().fg(Color::DarkGray),
));
}
if agent.active_subagents > 0 {
line1_spans.push(Span::styled(
format!(" \u{2442}{}", agent.active_subagents),
Style::default().fg(Color::Cyan),
));
}
let status_label = match (&agent.status, &agent.auto_approve_phase) {
(
AgentStatus::AwaitingApproval { approval_type, .. },
Some(AutoApprovePhase::Judging),
) => {
format!("Judging: {}", approval_type)
}
(
AgentStatus::AwaitingApproval { approval_type, .. },
Some(AutoApprovePhase::ApprovedByRule),
) => {
format!("Rule-Approved: {}", approval_type)
}
(
AgentStatus::AwaitingApproval { approval_type, .. },
Some(AutoApprovePhase::ApprovedByAi),
) => {
format!("AI-Approved: {}", approval_type)
}
(AgentStatus::Idle, _) => "Idle".to_string(),
(AgentStatus::Processing { activity }, _) => {
Self::processing_label(activity, show_activity_name)
}
(AgentStatus::AwaitingApproval { approval_type, .. }, _) => {
format!("Awaiting: {}", approval_type)
}
(AgentStatus::Error { .. }, _) => "Error".to_string(),
(AgentStatus::Offline, _) => "Offline".to_string(),
(AgentStatus::Unknown, _) => "Unknown".to_string(),
};
line1_spans.push(Span::styled(
format!(" {}", status_label),
Style::default().fg(status_color),
));
if agent.mode != AgentMode::Default {
line1_spans.push(Span::styled(
format!(" {}", agent.mode),
Style::default().fg(Color::Cyan),
));
}
if let Some(ref effort) = agent.effort_level {
line1_spans.push(Span::styled(
format!(" {}", effort),
Style::default().fg(Color::DarkGray),
));
}
if let Some(ref branch) = agent.git_branch {
let is_wt = agent.is_worktree.unwrap_or(false);
let (label, color) = if is_wt {
if let Some(ref wt_name) = agent.worktree_name {
(format!(" [WT: {} @ {}]", wt_name, branch), Color::Magenta)
} else {
(format!(" [WT: {}]", branch), Color::Magenta)
}
} else if agent.git_dirty.unwrap_or(false) {
(format!(" [{}]", branch), Color::Yellow)
} else {
(format!(" [{}]", branch), Color::Cyan)
};
line1_spans.push(Span::styled(label, Style::default().fg(color)));
}
let line1 = Line::from(line1_spans);
const DETAIL_MAX_WIDTH: usize = 40;
let title_source = agent
.team_info
.as_ref()
.and_then(|ti| ti.current_task.as_ref())
.and_then(|task| task.active_form.as_ref())
.cloned()
.or_else(|| {
if agent.title.is_empty() {
agent_def_description.map(|s| s.to_string())
} else {
None
}
})
.unwrap_or_else(|| agent.title.clone());
let title_text = if title_source.is_empty() {
"-".to_string()
} else {
get_marquee_text(&title_source, DETAIL_MAX_WIDTH, marquee_offset, is_selected)
};
let indent = if tree_prefix.is_empty() {
" ".to_string()
} else {
format!("{} ", " ".repeat(tree_prefix.width()))
};
let line2 = Line::from(vec![
Span::styled(indent, Style::default()),
Span::styled(title_text, Style::default().fg(Color::White)),
Span::styled(
format!(" pid:{}", agent.pid),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!(
" W:{}[{}] P:{}",
agent.window_index, agent.window_name, agent.pane_index
),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(vec![line1, line2])
}
fn processing_label(
activity: &tmai_core::agents::Activity,
show_activity_name: bool,
) -> String {
use tmai_core::agents::Activity;
if !show_activity_name {
return "Processing".to_string();
}
match activity {
Activity::ToolExecution { tool_name } => format!("Tool: {}", tool_name),
Activity::Compacting => "Compacting".to_string(),
Activity::Thinking => "Processing".to_string(),
Activity::Other(text) => {
let stripped =
text.trim_start_matches(|c: char| "·✢✳✶✻✽*".contains(c) || c.is_whitespace());
let verb = stripped.split(['\u{2026}', '.', ' ']).next().unwrap_or("");
if verb.is_empty() || !verb.starts_with(|c: char| c.is_uppercase()) {
"Processing".to_string()
} else {
verb.to_string()
}
}
}
}
fn status_color(status: &AgentStatus) -> Color {
match status {
AgentStatus::Idle => Color::Green,
AgentStatus::Processing { .. } => Color::Yellow,
AgentStatus::AwaitingApproval { .. } => Color::Magenta,
AgentStatus::Error { .. } => Color::Red,
AgentStatus::Offline => Color::DarkGray,
AgentStatus::Unknown => Color::Gray,
}
}
fn is_last_team_member(state: &AppState, agent_idx: usize) -> bool {
let agent = match state
.agent_order
.get(agent_idx)
.and_then(|id| state.agents.get(id))
{
Some(a) => a,
None => return false,
};
let team_name = match &agent.team_info {
Some(ti) if !ti.is_lead => &ti.team_name,
_ => return false,
};
if let Some(next_id) = state.agent_order.get(agent_idx + 1) {
if let Some(next_agent) = state.agents.get(next_id) {
if let Some(ref next_ti) = next_agent.team_info {
if &next_ti.team_name == team_name && !next_ti.is_lead {
return false;
}
}
}
}
true
}
fn get_tree_prefix(agent: &MonitoredAgent, state: &AppState, agent_idx: usize) -> String {
match &agent.team_info {
Some(ti) if !ti.is_lead => {
if Self::is_last_team_member(state, agent_idx) {
"\u{2514}\u{2500}".to_string() } else {
"\u{251C}\u{2500}".to_string() }
}
_ => String::new(),
}
}
fn create_compact_item(
agent: &MonitoredAgent,
spinner_char: char,
max_width: u16,
is_selected: bool,
marquee_offset: usize,
tree_prefix: &str,
show_activity_name: bool,
) -> ListItem<'static> {
let (status_indicator, status_color) = match (&agent.status, &agent.auto_approve_phase) {
(AgentStatus::AwaitingApproval { .. }, Some(AutoApprovePhase::Judging)) => {
("\u{27F3}".to_string(), Color::Cyan) }
(
AgentStatus::AwaitingApproval { .. },
Some(AutoApprovePhase::ApprovedByRule | AutoApprovePhase::ApprovedByAi),
) => ("\u{2713}".to_string(), Color::Green), (AgentStatus::Processing { .. }, _) => {
(spinner_char.to_string(), Self::status_color(&agent.status))
}
_ => (
agent.status.indicator().to_string(),
Self::status_color(&agent.status),
),
};
const STATUS_WIDTH: usize = 12; const PID_WIDTH: usize = 10; const SESSION_WIDTH: usize = 18;
let prefix_len = if tree_prefix.is_empty() {
0
} else {
tree_prefix.width() + 1
};
let fixed_len = 56_usize + prefix_len;
let title_width = (max_width as usize).saturating_sub(fixed_len).max(10);
let title_source = agent
.team_info
.as_ref()
.and_then(|ti| ti.current_task.as_ref())
.and_then(|task| task.active_form.as_ref())
.cloned()
.unwrap_or_else(|| agent.title.clone());
let title_display = if title_source.is_empty() {
fixed_width("-", title_width)
} else {
get_marquee_text(&title_source, title_width, marquee_offset, is_selected)
};
let status_text = match (&agent.status, &agent.auto_approve_phase) {
(AgentStatus::AwaitingApproval { .. }, Some(AutoApprovePhase::Judging)) => {
"Judging".to_string()
}
(AgentStatus::AwaitingApproval { .. }, Some(AutoApprovePhase::ApprovedByRule)) => {
"RuleOK".to_string()
}
(AgentStatus::AwaitingApproval { .. }, Some(AutoApprovePhase::ApprovedByAi)) => {
"AI-OK".to_string()
}
(AgentStatus::Idle, _) => "Idle".to_string(),
(AgentStatus::Processing { activity }, _) => {
Self::processing_label(activity, show_activity_name)
}
(AgentStatus::AwaitingApproval { .. }, _) => "Awaiting".to_string(),
(AgentStatus::Error { .. }, _) => "Error".to_string(),
(AgentStatus::Offline, _) => "Offline".to_string(),
(AgentStatus::Unknown, _) => "Unknown".to_string(),
};
let status_text = fixed_width(&status_text, STATUS_WIDTH);
let bg_color = if is_selected {
Color::DarkGray
} else {
Color::Reset
};
let session_info = format!(
"W:{}[{}] P:{}",
agent.window_index, agent.window_name, agent.pane_index
);
let mut spans: Vec<Span<'static>> = Vec::new();
if !tree_prefix.is_empty() {
spans.push(Span::styled(
format!("{} ", tree_prefix),
Style::default().fg(Color::DarkGray).bg(bg_color),
));
}
spans.extend([
Span::styled(
format!("{} ", status_indicator),
Style::default().fg(status_color).bg(bg_color),
),
Span::styled(
agent.agent_type.short_name().to_string(),
Style::default().fg(Color::Cyan).bg(bg_color),
),
Span::styled(" | ", Style::default().fg(Color::DarkGray).bg(bg_color)),
Span::styled(status_text, Style::default().fg(status_color).bg(bg_color)),
Span::styled(" | ", Style::default().fg(Color::DarkGray).bg(bg_color)),
Span::styled(
fixed_width(&format!("pid:{}", agent.pid), PID_WIDTH),
Style::default().fg(Color::DarkGray).bg(bg_color),
),
Span::styled(" | ", Style::default().fg(Color::DarkGray).bg(bg_color)),
Span::styled(
fixed_width(&session_info, SESSION_WIDTH),
Style::default().fg(Color::White).bg(bg_color),
),
Span::styled(" | ", Style::default().fg(Color::DarkGray).bg(bg_color)),
Span::styled(
title_display,
Style::default().fg(Color::White).bg(bg_color),
),
]);
if agent.mode != AgentMode::Default {
spans.push(Span::styled(
format!(" {}", agent.mode),
Style::default().fg(Color::Cyan).bg(bg_color),
));
}
if let Some(ref effort) = agent.effort_level {
spans.push(Span::styled(
format!(" {}", effort),
Style::default().fg(Color::DarkGray).bg(bg_color),
));
}
if let Some(ref team_info) = agent.team_info {
spans.push(Span::styled(
format!(" [{}/{}]", team_info.team_name, team_info.member_name),
Style::default().fg(Color::Magenta).bg(bg_color),
));
}
if let Some(ref branch) = agent.git_branch {
let is_wt = agent.is_worktree.unwrap_or(false);
let (label, color) = if is_wt {
if let Some(ref wt_name) = agent.worktree_name {
(format!(" [WT: {} @ {}]", wt_name, branch), Color::Magenta)
} else {
(format!(" [WT: {}]", branch), Color::Magenta)
}
} else if agent.git_dirty.unwrap_or(false) {
(format!(" [{}]", branch), Color::Yellow)
} else {
(format!(" [{}]", branch), Color::Cyan)
};
spans.push(Span::styled(label, Style::default().fg(color).bg(bg_color)));
}
ListItem::new(Line::from(spans))
}
#[allow(clippy::too_many_arguments)]
fn create_compact_group_header(
header: &str,
max_width: u16,
agent_count: usize,
attention_count: usize,
collapsed: bool,
is_selected: bool,
marquee_offset: usize,
task_summary: Option<&GroupTaskSummary>,
worktree_count: Option<usize>,
) -> ListItem<'static> {
let icon = if collapsed { "\u{25B8}" } else { "\u{25BE}" };
let reserved = if task_summary.is_some() {
30_usize
} else {
15_usize
};
let available = (max_width as usize).saturating_sub(reserved);
let display = get_marquee_text_path(header, available, marquee_offset, is_selected);
let bg_color = if is_selected {
Color::DarkGray
} else {
Color::Reset
};
let mut spans = vec![Span::styled(
format!("{} {} ", icon, display.trim_end()),
Style::default()
.fg(Color::Blue)
.bg(bg_color)
.add_modifier(Modifier::BOLD),
)];
let count_text = match worktree_count {
Some(wt) if wt > 0 => format!("({}, {} WT)", agent_count, wt),
_ => format!("({})", agent_count),
};
spans.push(Span::styled(
count_text,
Style::default().fg(Color::DarkGray).bg(bg_color),
));
if attention_count > 0 {
spans.push(Span::styled(
format!(" \u{26A0}{}", attention_count),
Style::default().fg(Color::Red).bg(bg_color),
));
}
if let Some(summary) = task_summary {
if summary.total > 0 {
spans.push(Span::styled(
format!(" Tasks: {}/{}", summary.done, summary.total),
Style::default().fg(Color::Yellow).bg(bg_color),
));
}
}
ListItem::new(Line::from(spans))
}
fn create_compact_new_item(_max_width: u16, is_selected: bool) -> ListItem<'static> {
let bg_color = if is_selected {
Color::DarkGray
} else {
Color::Reset
};
ListItem::new(Line::from(vec![Span::styled(
"+ New",
Style::default()
.fg(Color::Cyan)
.bg(bg_color)
.add_modifier(Modifier::ITALIC),
)]))
}
}
fn get_marquee_text_path(text: &str, max_width: usize, offset: usize, is_selected: bool) -> String {
let text_width = text.width();
if text_width <= max_width {
let padding = max_width.saturating_sub(text_width);
return format!("{}{}", text, " ".repeat(padding));
}
if !is_selected {
return truncate_path_with_ellipsis(text, max_width);
}
let padding = " "; let looped_text = format!("{}{}{}", text, padding, text);
let loop_length = text_width + padding.width();
let effective_offset = offset % loop_length;
extract_substring_by_width(&looped_text, effective_offset, max_width)
}
fn get_marquee_text(text: &str, max_width: usize, offset: usize, is_selected: bool) -> String {
let text_width = text.width();
if text_width <= max_width {
let padding = max_width.saturating_sub(text_width);
return format!("{}{}", text, " ".repeat(padding));
}
if !is_selected {
return truncate_to_width_with_ellipsis(text, max_width);
}
let padding = " "; let looped_text = format!("{}{}{}", text, padding, text);
let loop_length = text_width + padding.width();
let effective_offset = offset % loop_length;
extract_substring_by_width(&looped_text, effective_offset, max_width)
}
fn truncate_path_with_ellipsis(s: &str, max_width: usize) -> String {
if max_width <= 3 {
return truncate_to_width(s, max_width);
}
let s_width = s.width();
if s_width <= max_width {
let padding = max_width.saturating_sub(s_width);
return format!("{}{}", s, " ".repeat(padding));
}
let tail_max = max_width.saturating_sub(3);
let chars: Vec<char> = s.chars().collect();
let mut tail_start = chars.len();
let mut tail_width = 0;
for i in (0..chars.len()).rev() {
let cw = unicode_width::UnicodeWidthChar::width(chars[i]).unwrap_or(0);
if tail_width + cw > tail_max {
break;
}
tail_width += cw;
tail_start = i;
}
let tail: String = chars[tail_start..].iter().collect();
let padding = max_width.saturating_sub(3 + tail_width);
format!("...{}{}", tail, " ".repeat(padding))
}
fn truncate_to_width_with_ellipsis(s: &str, max_width: usize) -> String {
if max_width <= 3 {
return truncate_to_width(s, max_width);
}
let truncated = truncate_to_width(s, max_width.saturating_sub(3));
let truncated_width = truncated.width();
if truncated_width < s.width() {
format!("{}...", truncated)
} else {
let padding = max_width.saturating_sub(truncated_width);
format!("{}{}", truncated, " ".repeat(padding))
}
}
fn extract_substring_by_width(s: &str, start_offset: usize, max_width: usize) -> String {
let mut result = String::new();
let mut current_width = 0;
let mut skip_width = 0;
let mut started = false;
for c in s.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if !started {
if skip_width + char_width > start_offset {
started = true;
} else {
skip_width += char_width;
if skip_width >= start_offset {
started = true;
}
continue;
}
}
if current_width + char_width > max_width {
break;
}
result.push(c);
current_width += char_width;
}
let padding = max_width.saturating_sub(current_width);
format!("{}{}", result, " ".repeat(padding))
}
fn fixed_width(s: &str, width: usize) -> String {
let display_width = s.width();
if display_width >= width {
if width <= 3 {
truncate_to_width(s, width)
} else {
let truncated = truncate_to_width(s, width.saturating_sub(3));
format!("{}...", truncated)
}
} else {
let padding = width - display_width;
format!("{}{}", s, " ".repeat(padding))
}
}
fn truncate_to_width(s: &str, max_width: usize) -> String {
let mut result = String::new();
let mut current_width = 0;
for c in s.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > max_width {
break;
}
result.push(c);
current_width += char_width;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_marquee_text_short() {
let result = get_marquee_text("short", 10, 0, false);
assert_eq!(result.len(), 10);
assert!(result.starts_with("short"));
}
#[test]
fn test_get_marquee_text_non_selected() {
let result = get_marquee_text("this is a long string", 10, 0, false);
assert!(result.ends_with("..."));
assert_eq!(result.width(), 10);
}
#[test]
fn test_get_marquee_text_selected() {
let text = "this is a long string";
let result_0 = get_marquee_text(text, 10, 0, true);
let result_1 = get_marquee_text(text, 10, 1, true);
assert_ne!(result_0, result_1);
}
#[test]
fn test_truncate_path_with_ellipsis_short() {
let result = truncate_path_with_ellipsis("/short", 20);
assert!(result.starts_with("/short"));
assert_eq!(result.width(), 20);
}
#[test]
fn test_truncate_path_with_ellipsis_long() {
let result =
truncate_path_with_ellipsis("/home/trustdelta/works/conversation-handoff-mcp", 30);
assert!(result.starts_with("..."));
assert_eq!(result.width(), 30);
assert!(result.contains("handoff-mcp"));
}
#[test]
fn test_get_marquee_text_path_non_selected() {
let result = get_marquee_text_path(
"/home/trustdelta/works/conversation-handoff-mcp",
30,
0,
false,
);
assert!(result.starts_with("..."));
assert!(result.contains("handoff-mcp"));
}
#[test]
fn test_get_marquee_text_path_selected() {
let text = "/home/trustdelta/works/conversation-handoff-mcp";
let result_0 = get_marquee_text_path(text, 20, 0, true);
let result_1 = get_marquee_text_path(text, 20, 1, true);
assert_ne!(result_0, result_1);
}
#[test]
fn test_processing_label_compacting() {
use tmai_core::agents::Activity;
assert_eq!(
SessionList::processing_label(&Activity::Compacting, true),
"Compacting"
);
}
#[test]
fn test_processing_label_default() {
use tmai_core::agents::Activity;
assert_eq!(
SessionList::processing_label(&Activity::Thinking, true),
"Processing"
);
assert_eq!(
SessionList::processing_label(&Activity::Other("tasks running".to_string()), true),
"Processing"
);
}
#[test]
fn test_processing_label_various_verbs() {
use tmai_core::agents::Activity;
assert_eq!(
SessionList::processing_label(&Activity::Other("Cerebrating…".to_string()), true),
"Cerebrating"
);
assert_eq!(
SessionList::processing_label(
&Activity::Other("✻ Levitating… (2m · ↓ 13 tokens)".to_string()),
true
),
"Levitating"
);
assert_eq!(
SessionList::processing_label(&Activity::Other("· Gallivanting…".to_string()), true),
"Gallivanting"
);
assert_eq!(
SessionList::processing_label(&Activity::Other("✶ Crunching…".to_string()), true),
"Crunching"
);
assert_eq!(
SessionList::processing_label(&Activity::Other("Tasks running".to_string()), true),
"Tasks"
);
assert_eq!(
SessionList::processing_label(&Activity::Compacting, false),
"Processing"
);
assert_eq!(
SessionList::processing_label(&Activity::Other("Cerebrating…".to_string()), false),
"Processing"
);
}
#[test]
fn test_processing_label_hook_tool_name() {
use tmai_core::agents::Activity;
assert_eq!(
SessionList::processing_label(
&Activity::ToolExecution {
tool_name: "Bash".to_string()
},
true
),
"Tool: Bash"
);
assert_eq!(
SessionList::processing_label(
&Activity::ToolExecution {
tool_name: "Read".to_string()
},
true
),
"Tool: Read"
);
assert_eq!(
SessionList::processing_label(
&Activity::ToolExecution {
tool_name: "Edit".to_string()
},
true
),
"Tool: Edit"
);
assert_eq!(
SessionList::processing_label(
&Activity::ToolExecution {
tool_name: "Agent".to_string()
},
true
),
"Tool: Agent"
);
assert_eq!(
SessionList::processing_label(
&Activity::ToolExecution {
tool_name: "Bash".to_string()
},
false
),
"Processing"
);
}
}