use crate::agent::{Agent, AgentMode, AgentStatus, WorkState};
use crate::app::{App, AppMode, FocusedPane, InputMode, ReviewFocus};
use cctakt::{available_themes, current_theme_id, issue_picker::centered_rect, theme};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
pub fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(2), ])
.split(f.area());
render_header(f, app, chunks[0]);
render_footer(f, app, chunks[2]);
if app.agent_manager.is_empty() {
render_no_agent_menu(f, chunks[1]);
} else {
render_split_pane_main_area(f, app, chunks[1]);
}
match app.mode {
AppMode::IssuePicker => {
let popup_area = centered_rect(80, 70, f.area());
app.issue_picker.render(f, popup_area);
}
AppMode::ThemePicker => {
render_theme_picker(f, app, f.area());
}
AppMode::ReviewMerge | AppMode::Normal => {}
}
if !app.notifications.is_empty() {
render_notifications(f, app, f.area());
}
}
pub fn render_notifications(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let notification_count = app.notifications.len().min(3); if notification_count == 0 {
return;
}
let height = notification_count as u16 + 2; let notification_area = ratatui::layout::Rect {
x: area.x + 2,
y: area.height.saturating_sub(height + 1),
width: area.width.saturating_sub(4).min(60),
height,
};
let t = theme();
let lines: Vec<Line> = app
.notifications
.iter()
.rev()
.take(3)
.map(|n| {
let (prefix, style) = match n.level {
cctakt::plan::NotifyLevel::Info => ("ℹ", t.style_info()),
cctakt::plan::NotifyLevel::Warning => ("⚠", t.style_warning()),
cctakt::plan::NotifyLevel::Error => ("✗", t.style_error()),
cctakt::plan::NotifyLevel::Success => ("✓", t.style_success()),
};
Line::from(vec![
Span::styled(format!(" {prefix} "), style),
Span::raw(&n.message),
])
})
.collect();
let notification_widget = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(t.style_border_muted()),
);
f.render_widget(Clear, notification_area);
f.render_widget(notification_widget, notification_area);
}
pub fn render_theme_picker(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let t = theme();
let themes = available_themes();
let current_theme_id_str = current_theme_id().id();
let popup_width = 40u16;
let popup_height = (themes.len() as u16) + 6;
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_area = ratatui::layout::Rect {
x: popup_x,
y: popup_y,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
};
f.render_widget(Clear, popup_area);
let mut lines: Vec<Line> = vec![Line::from("")];
for (i, (id, name, description)) in themes.iter().enumerate() {
let is_selected = i == app.theme_picker_index;
let is_current = *id == current_theme_id_str;
let prefix = if is_selected { " > " } else { " " };
let suffix = if is_current { " ✓" } else { "" };
let style = if is_selected {
Style::default()
.fg(t.neon_cyan())
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default().fg(t.neon_green())
} else {
t.style_text()
};
lines.push(Line::from(vec![
Span::styled(prefix, style),
Span::styled(*name, style),
Span::styled(suffix, Style::default().fg(t.neon_green())),
]));
if is_selected {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(*description, t.style_text_muted()),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Enter", t.style_key()),
Span::styled(": Select ", t.style_key_desc()),
Span::styled("Esc", t.style_key()),
Span::styled(": Cancel", t.style_key_desc()),
]));
let block = Block::default()
.title(Span::styled(
" テーマを選択 ",
Style::default()
.fg(t.neon_cyan())
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(t.style_dialog_border())
.style(t.style_dialog_bg());
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, popup_area);
}
pub fn render_review_merge(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let Some(ref mut state) = app.review_state else {
return;
};
let t = theme();
f.render_widget(Clear, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35), Constraint::Percentage(65), Constraint::Length(1), ])
.split(area);
let summary_focused = state.focus == ReviewFocus::Summary;
let diff_focused = state.focus == ReviewFocus::Diff;
let summary_border_color = if summary_focused {
t.neon_cyan()
} else {
t.border_secondary()
};
let diff_border_color = if diff_focused {
t.neon_cyan()
} else {
t.border_secondary()
};
render_summary_pane(f, state, chunks[0], summary_border_color);
let diff_block = Block::default()
.title(format!(" Diff: {} → main ", state.branch))
.borders(Borders::ALL)
.border_style(Style::default().fg(diff_border_color));
state.diff_view.render_with_block(f, chunks[1], diff_block);
let footer = Paragraph::new(Line::from(vec![
Span::styled("[i/Enter]", t.style_key()),
Span::styled(" Focus ", t.style_text_muted()),
Span::styled("[j/k]", t.style_key()),
Span::styled(" Scroll ", t.style_text_muted()),
Span::styled("[M]", t.style_success()),
Span::styled(" Merge ", t.style_text_muted()),
Span::styled("[Q/C]", t.style_error()),
Span::styled(" Cancel", t.style_text_muted()),
]));
f.render_widget(footer, chunks[2]);
}
fn render_summary_pane(
f: &mut Frame,
state: &crate::app::types::ReviewState,
area: ratatui::layout::Rect,
border_color: Color,
) {
let t = theme();
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(vec![
Span::styled(
" Review Merge: ",
Style::default()
.fg(t.neon_cyan())
.add_modifier(Modifier::BOLD),
),
Span::styled(&state.branch, Style::default().fg(t.neon_yellow())),
Span::raw(" → "),
Span::styled("main", Style::default().fg(t.success())),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" Stats: "),
Span::styled(format!("{} files", state.files_changed), t.style_text()),
Span::raw(", "),
Span::styled(
format!("+{}", state.insertions),
Style::default().fg(t.success()),
),
Span::raw(" / "),
Span::styled(
format!("-{}", state.deletions),
Style::default().fg(t.error()),
),
]));
if !state.conflicts.is_empty() {
lines.push(Line::from(vec![
Span::styled(" ⚠ Potential conflicts: ", t.style_warning()),
Span::styled(state.conflicts.join(", "), t.style_warning()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
" Commits:",
Style::default()
.fg(t.neon_cyan())
.add_modifier(Modifier::BOLD),
)]));
let content_height = area.height.saturating_sub(2) as usize; let log_lines: Vec<&str> = state.commit_log.lines().collect();
let start = state.summary_scroll as usize;
let visible_log_lines = log_lines
.iter()
.skip(start)
.take(content_height.saturating_sub(lines.len()));
for log_line in visible_log_lines {
let styled_line = if log_line.starts_with(" ") {
Line::from(Span::styled(
log_line.to_string(),
t.style_text_secondary(),
))
} else if log_line.contains(' ') {
let parts: Vec<&str> = log_line.splitn(2, ' ').collect();
if parts.len() == 2 {
Line::from(vec![
Span::styled(
format!(" {} ", parts[0]),
Style::default().fg(t.neon_yellow()),
),
Span::styled(parts[1].to_string(), t.style_text()),
])
} else {
Line::from(Span::styled(format!(" {log_line}"), t.style_text()))
}
} else {
Line::from(Span::styled(format!(" {log_line}"), t.style_text()))
};
lines.push(styled_line);
}
let summary_block = Block::default()
.title(" Summary ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let summary_widget = Paragraph::new(lines).block(summary_block);
f.render_widget(summary_widget, area);
}
pub fn render_header(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let t = theme();
let mut spans: Vec<Span> = vec![
Span::styled(
" cctakt ",
Style::default()
.fg(t.tab_active_fg())
.bg(t.neon_pink())
.add_modifier(Modifier::BOLD),
),
Span::styled(
concat!("v", env!("CARGO_PKG_VERSION"), " "),
t.style_text_muted(),
),
];
let agents = app.agent_manager.list();
let active_index = app.agent_manager.active_index();
for (i, agent) in agents.iter().enumerate() {
let is_active = i == active_index;
let is_ended = agent.status == AgentStatus::Ended;
let tab_content = format!(" [{}:{}] ", i + 1, agent.name);
let style = if is_active {
t.style_tab_active()
} else if is_ended {
Style::default().fg(t.status_ended())
} else {
t.style_tab_inactive()
};
spans.push(Span::styled(tab_content, style));
}
let header = Paragraph::new(Line::from(spans));
f.render_widget(header, area);
}
pub fn render_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let t = theme();
let agents = app.agent_manager.list();
let mut running_count = 0;
let mut idle_count = 0;
let mut completed_count = 0;
for agent in agents {
match agent.work_state {
WorkState::Starting | WorkState::Working => running_count += 1,
WorkState::Idle => idle_count += 1,
WorkState::Completed => completed_count += 1,
}
}
let total_agents = agents.len();
let mut left_spans: Vec<Span> = vec![];
if total_agents > 0 {
left_spans.push(Span::styled(
format!(" Agents: {total_agents} "),
t.style_text_muted(),
));
left_spans.push(Span::styled(
format!("Running: {running_count}"),
if running_count > 0 {
t.style_warning()
} else {
t.style_text_muted()
},
));
left_spans.push(Span::styled(" | ", t.style_text_muted()));
left_spans.push(Span::styled(
format!("Idle: {idle_count}"),
if idle_count > 0 {
t.style_info()
} else {
t.style_text_muted()
},
));
left_spans.push(Span::styled(" | ", t.style_text_muted()));
left_spans.push(Span::styled(
format!("Completed: {completed_count}"),
if completed_count > 0 {
t.style_success()
} else {
t.style_text_muted()
},
));
let (total_cost, total_turns) = agents
.iter()
.filter(|a| a.mode == AgentMode::NonInteractive)
.fold((0.0, 0u32), |(cost, turns), agent| {
(
cost + agent.cost_usd.unwrap_or(0.0),
turns + agent.num_turns.unwrap_or(0),
)
});
if total_cost > 0.0 || total_turns > 0 {
left_spans.push(Span::styled(" | ", t.style_text_muted()));
left_spans.push(Span::styled(
if total_cost < 0.01 {
"<$0.01".to_string()
} else {
format!("${:.2}", total_cost)
},
Style::default().fg(t.neon_yellow()),
));
if total_turns > 0 {
left_spans.push(Span::styled(
format!(" ({} turns)", total_turns),
t.style_text_muted(),
));
}
}
}
left_spans.push(Span::styled(" | ", t.style_text_muted()));
let (mode_text, mode_style) = match app.input_mode {
InputMode::Navigation => ("NAV(i:入力 ::cmd)", t.style_warning()),
InputMode::Input => ("INS(Esc:移動)", t.style_success()),
InputMode::Command => {
let cmd_display = format!(":{}▌", app.command_buffer);
left_spans.push(Span::styled(cmd_display, t.style_success()));
("", t.style_text_muted()) }
};
if !mode_text.is_empty() {
left_spans.push(Span::styled(mode_text, mode_style));
}
let pane_text = match app.focused_pane {
FocusedPane::Left => " [←]",
FocusedPane::Right => " [→]",
};
left_spans.push(Span::styled(pane_text, t.style_text_muted()));
let mut right_spans: Vec<Span> = vec![];
if let Some(ref plan) = app.current_plan {
let (pending, running, completed, failed) = plan.count_by_status();
let total = plan.tasks.len();
let plan_style = if failed > 0 {
t.style_error()
} else if running > 0 {
t.style_warning()
} else {
t.style_success()
};
right_spans.push(Span::styled(
format!("Plan: {completed}/{total} "),
plan_style,
));
let _ = pending;
}
let left_text: String = left_spans.iter().map(|s| s.content.as_ref()).collect();
let right_text: String = right_spans.iter().map(|s| s.content.as_ref()).collect();
let left_width = left_text.len();
let right_width = right_text.len();
let available_width = area.width as usize;
let mut line1_spans = left_spans;
let padding = available_width.saturating_sub(left_width + right_width);
if padding > 0 {
line1_spans.push(Span::raw(" ".repeat(padding)));
}
line1_spans.extend(right_spans);
let line1 = Line::from(line1_spans);
let keymap_spans = vec![Span::styled(
" [^T:new ^I:issue ^W:close ^N/^P:switch ^Q:quit]",
t.style_text_muted(),
)];
let line2 = Line::from(keymap_spans);
let footer = Paragraph::new(vec![line1, line2]).style(Style::default().bg(t.bg_surface()));
f.render_widget(footer, area);
}
pub fn render_split_pane_main_area(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let interactive = app.agent_manager.get_interactive();
let active_worker = app.agent_manager.get_active_non_interactive();
let is_review_mode = app.mode == AppMode::ReviewMerge;
match (interactive, active_worker, is_review_mode) {
(Some(orchestrator), _, true) => {
let t = theme();
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Length(1), Constraint::Percentage(50),
])
.split(area);
if orchestrator.status == AgentStatus::Ended {
render_ended_agent(f, orchestrator, main_chunks[0], None);
} else {
render_agent_screen(f, orchestrator, main_chunks[0], None);
}
let separator_lines: Vec<Line> = (0..main_chunks[1].height)
.map(|_| Line::from("│"))
.collect();
let separator =
Paragraph::new(separator_lines).style(Style::default().fg(t.border_secondary()));
f.render_widget(separator, main_chunks[1]);
render_review_merge(f, app, main_chunks[2]);
}
(None, _, true) => {
render_review_merge(f, app, area);
}
(Some(orchestrator), Some(worker), false) => {
let t = theme();
let left_focused = app.focused_pane == FocusedPane::Left;
let right_focused = app.focused_pane == FocusedPane::Right;
let left_focus_color = if left_focused {
Some(t.neon_cyan())
} else {
None
};
let right_focus_color = if right_focused {
Some(t.neon_pink())
} else {
None
};
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Length(1), Constraint::Percentage(50),
])
.split(area);
if orchestrator.status == AgentStatus::Ended {
render_ended_agent(f, orchestrator, main_chunks[0], left_focus_color);
} else {
render_agent_screen(f, orchestrator, main_chunks[0], left_focus_color);
}
let separator_color = if left_focused || right_focused {
if left_focused {
t.neon_cyan()
} else {
t.neon_pink()
}
} else {
t.border_secondary()
};
let separator_lines: Vec<Line> = (0..main_chunks[1].height)
.map(|_| Line::from("│"))
.collect();
let separator =
Paragraph::new(separator_lines).style(Style::default().fg(separator_color));
f.render_widget(separator, main_chunks[1]);
if worker.status == AgentStatus::Ended {
render_ended_agent(f, worker, main_chunks[2], right_focus_color);
} else {
render_agent_screen(f, worker, main_chunks[2], right_focus_color);
}
}
(Some(orchestrator), None, false) => {
let t = theme();
let focus_color = Some(t.neon_cyan());
if orchestrator.status == AgentStatus::Ended {
render_ended_agent(f, orchestrator, area, focus_color);
} else {
render_agent_screen(f, orchestrator, area, focus_color);
}
}
(None, Some(worker), false) => {
let t = theme();
let focus_color = Some(t.neon_pink());
if worker.status == AgentStatus::Ended {
render_ended_agent(f, worker, area, focus_color);
} else {
render_agent_screen(f, worker, area, focus_color);
}
}
(None, None, false) => {
render_no_agent_menu(f, area);
}
}
}
pub fn render_no_agent_menu(f: &mut Frame, area: ratatui::layout::Rect) {
let t = theme();
let menu = Paragraph::new(vec![
Line::from(""),
Line::from(" No active agents."),
Line::from(""),
Line::from(vec![
Span::styled(" [N]", t.style_success()),
Span::raw(" New agent"),
]),
Line::from(vec![
Span::styled(" [I/F2]", t.style_info()),
Span::raw(" New agent from GitHub issue"),
]),
Line::from(vec![
Span::styled(" [Q]", t.style_error()),
Span::raw(" Quit cctakt"),
]),
Line::from(""),
Line::from(Span::styled(
" Press N, I, or Q...",
t.style_text_muted(),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_style(t.style_border_muted()),
);
f.render_widget(menu, area);
}
pub fn render_ended_agent(
f: &mut Frame,
agent: &Agent,
area: ratatui::layout::Rect,
focus_color: Option<Color>,
) {
let t = theme();
let border_style = match focus_color {
Some(color) => Style::default().fg(color),
None => t.style_border_muted(),
};
let ended_message = if let Some(ref branch) = agent.branch {
format!(" Agent '{}' session ended. ({})", agent.name, branch)
} else {
format!(" Agent '{}' session ended.", agent.name)
};
let menu = Paragraph::new(vec![
Line::from(""),
Line::from(ended_message),
Line::from(""),
Line::from(vec![
Span::styled(" [Ctrl+W]", t.style_warning()),
Span::raw(" Close this tab"),
]),
Line::from(vec![
Span::styled(" [Ctrl+N/P]", Style::default().fg(t.neon_blue())),
Span::raw(" Switch to another tab"),
]),
Line::from(vec![
Span::styled(" [Ctrl+Q]", t.style_error()),
Span::raw(" Quit"),
]),
Line::from(""),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} (ended) ", agent.name))
.border_style(border_style),
);
f.render_widget(menu, area);
}
pub fn render_agent_screen(
f: &mut Frame,
agent: &Agent,
area: ratatui::layout::Rect,
focus_color: Option<Color>,
) {
match agent.mode {
AgentMode::Interactive => {
render_agent_screen_interactive(f, agent, area, focus_color);
}
AgentMode::NonInteractive => {
render_agent_screen_non_interactive(f, agent, area, focus_color);
}
}
}
pub fn render_agent_screen_interactive(
f: &mut Frame,
agent: &Agent,
area: ratatui::layout::Rect,
focus_color: Option<Color>,
) {
let t = theme();
let border_style = match focus_color {
Some(color) => Style::default().fg(color),
None => t.style_border_muted(),
};
let Some(parser_arc) = agent.get_parser() else {
let widget = Paragraph::new("No parser available").block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
f.render_widget(widget, area);
return;
};
let parser = parser_arc.lock().unwrap();
let screen = parser.screen();
let content_height = area.height.saturating_sub(2) as usize;
let content_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for row in 0..content_height {
let mut spans: Vec<Span> = Vec::new();
let mut current_text = String::new();
let mut current_style = Style::default();
for col in 0..content_width {
let cell = screen.cell(row as u16, col as u16);
if let Some(cell) = cell {
let cell_style = cell_to_style(cell);
if cell_style != current_style {
if !current_text.is_empty() {
spans.push(Span::styled(current_text.clone(), current_style));
current_text.clear();
}
current_style = cell_style;
}
current_text.push_str(&cell.contents());
}
}
if !current_text.is_empty() {
spans.push(Span::styled(current_text, current_style));
}
lines.push(Line::from(spans));
}
let terminal_widget = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
f.render_widget(terminal_widget, area);
}
pub fn render_agent_screen_non_interactive(
f: &mut Frame,
agent: &Agent,
area: ratatui::layout::Rect,
focus_color: Option<Color>,
) {
let t = theme();
let border_style = match focus_color {
Some(color) => Style::default().fg(color),
None => t.style_border_muted(),
};
let content_height = area.height.saturating_sub(2) as usize;
let output = agent.screen_text();
let all_lines: Vec<Line> = output
.lines()
.filter_map(|line| {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
format_json_event(&json)
} else if !line.trim().is_empty() {
Some(Line::from(Span::raw(line.to_string())))
} else {
None
}
})
.collect();
let start = all_lines.len().saturating_sub(content_height);
let visible_lines: Vec<Line> = all_lines[start..].to_vec();
let status_style = match agent.work_state {
WorkState::Working => Style::default().fg(Color::Yellow),
WorkState::Completed => {
if agent.error.is_some() {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
}
}
_ => Style::default().fg(Color::Gray),
};
let status_text = match agent.work_state {
WorkState::Starting => "Starting...",
WorkState::Working => "Working...",
WorkState::Idle => "Idle",
WorkState::Completed => {
if agent.error.is_some() {
"Error"
} else {
"Completed"
}
}
};
let terminal_widget = Paragraph::new(visible_lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(format!(" {status_text} "), status_style)),
);
f.render_widget(terminal_widget, area);
}
fn cell_to_style(cell: &vt100::Cell) -> Style {
let mut style = Style::default();
let fg = cell.fgcolor();
if !matches!(fg, vt100::Color::Default) {
style = style.fg(vt100_color_to_ratatui(fg));
}
let bg = cell.bgcolor();
if !matches!(bg, vt100::Color::Default) {
style = style.bg(vt100_color_to_ratatui(bg));
}
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
style = style.add_modifier(Modifier::REVERSED);
}
style
}
fn vt100_color_to_ratatui(color: vt100::Color) -> Color {
match color {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(0) => Color::Black,
vt100::Color::Idx(1) => Color::Red,
vt100::Color::Idx(2) => Color::Green,
vt100::Color::Idx(3) => Color::Yellow,
vt100::Color::Idx(4) => Color::Blue,
vt100::Color::Idx(5) => Color::Magenta,
vt100::Color::Idx(6) => Color::Cyan,
vt100::Color::Idx(7) => Color::Gray,
vt100::Color::Idx(8) => Color::DarkGray,
vt100::Color::Idx(9) => Color::LightRed,
vt100::Color::Idx(10) => Color::LightGreen,
vt100::Color::Idx(11) => Color::LightYellow,
vt100::Color::Idx(12) => Color::LightBlue,
vt100::Color::Idx(13) => Color::LightMagenta,
vt100::Color::Idx(14) => Color::LightCyan,
vt100::Color::Idx(15) => Color::White,
vt100::Color::Idx(idx) => Color::Indexed(idx),
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
fn format_json_event(json: &serde_json::Value) -> Option<Line<'static>> {
let event_type = json.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
match event_type {
"system" => {
let subtype = json.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
Some(Line::from(vec![
Span::styled("[SYS] ", Style::default().fg(Color::Blue)),
Span::raw(subtype.to_string()),
]))
}
"user" => {
None
}
"assistant" => {
let text: String = json
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|block| {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
block.get("text").and_then(|t| t.as_str())
} else {
None }
})
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
if text.trim().is_empty() {
return None;
}
let display_text: String = if text.chars().count() > 80 {
format!("{}...", text.chars().take(80).collect::<String>())
} else {
text
};
Some(Line::from(vec![
Span::styled("[AI] ", Style::default().fg(Color::Cyan)),
Span::raw(display_text),
]))
}
"result" => {
let subtype = json.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
let style = if subtype == "success" {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
};
Some(Line::from(vec![
Span::styled("[DONE] ", style),
Span::raw(subtype.to_string()),
]))
}
_ => None, }
}