use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::ai::AiState;
use crate::ai::transcript::Turn;
use crate::app::App;
use crate::layout::PaneId;
use crate::pane::Pane;
use crate::ui::{md_preview, theme};
pub fn draw(
frame: &mut Frame,
app: &mut App,
pane_id: PaneId,
area: Rect,
_focused: bool,
) -> Option<(u16, u16)> {
if area.width == 0 || area.height == 0 {
return None;
}
let t = theme::cur();
frame.render_widget(
Paragraph::new("").style(Style::default().bg(t.bg_dark)),
area,
);
let Some(Pane::Ai(ai)) = app.panes.get_mut(pane_id) else {
return None;
};
let dim = Style::default().fg(t.comment).bg(t.bg_dark);
let body = Style::default().fg(t.fg).bg(t.bg_dark);
let mut rows: Vec<Line> = Vec::new();
rows.push(Line::from(Span::styled(
format!("✦ {}", ai.title),
Style::default()
.fg(t.purple)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)));
let prompt_first = ai.prompt.lines().next().unwrap_or("").trim();
if !prompt_first.is_empty() {
rows.push(Line::from(Span::styled(format!(" {prompt_first}"), dim)));
}
let hint: String = if ai.is_live() {
" live mirror · c open interactive pane · G follow · esc → tree".into()
} else if matches!(ai.state, AiState::Asking | AiState::Streaming(_)) {
" x cancel · esc → tree".into()
} else if ai.pending_apply.is_some() {
" a confirm apply · r re-ask (discards) · c continue in Claude Code · esc → tree".into()
} else if ai.target.is_some() && matches!(ai.state, AiState::Done(_)) {
" r re-ask · a apply suggestion · c continue in Claude Code · esc → tree".into()
} else {
" r re-ask · c continue in Claude Code · esc → tree".into()
};
rows.push(Line::from(Span::styled(hint, dim)));
rows.push(Line::from(Span::styled(
"─".repeat(area.width as usize),
Style::default().fg(t.line).bg(t.bg_dark),
)));
match &ai.state {
AiState::Asking => rows.push(Line::from(Span::styled(
" ⟳ thinking… (claude -p)".to_string(),
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
))),
AiState::Failed(e) => rows.push(Line::from(Span::styled(
format!(" ✗ {e}"),
Style::default()
.fg(t.red)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
))),
AiState::Streaming(text) => {
rows.extend(md_preview::render_markdown(text));
rows.push(Line::from(Span::styled(
" ▌ …",
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)));
}
AiState::Done(text) => rows.extend(md_preview::render_markdown(text)),
AiState::Live { turns, .. } => {
if turns.is_empty() {
rows.push(Line::from(Span::styled(" (no messages yet)", dim)));
}
for turn in turns {
render_turn(turn, &t, body, dim, &mut rows);
}
}
}
if let Some(p) = &ai.pending_apply {
rows.push(Line::from(Span::styled(String::new(), body)));
rows.push(Line::from(Span::styled(
format!(
" ── proposed change to {} ──",
p.target.path.file_name().map_or_else(
|| p.target.path.display().to_string(),
|n| n.to_string_lossy().into_owned()
)
),
Style::default()
.fg(t.purple)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)));
for dl in &p.diff {
let (mark, text, color) = match dl {
crate::ai::DiffLine::Ctx(s) => (" ", s, t.comment),
crate::ai::DiffLine::Del(s) => ("-", s, t.red),
crate::ai::DiffLine::Add(s) => ("+", s, t.green),
};
rows.push(Line::from(Span::styled(
format!(" {mark} {text}"),
Style::default().fg(color).bg(t.bg_dark),
)));
}
}
let rows = md_preview::wrap_lines(rows, area.width as usize);
let h = area.height as usize;
let max_scroll = rows.len().saturating_sub(h.min(rows.len()));
if matches!(ai.state, AiState::Streaming(_)) {
ai.scroll = max_scroll;
}
ai.scroll = ai.scroll.min(max_scroll);
let view: Vec<Line> = rows.into_iter().skip(ai.scroll).take(h).collect();
frame.render_widget(
Paragraph::new(view).style(Style::default().bg(t.bg_dark)),
area,
);
app.rects.editor_panes.push((area, pane_id));
None
}
fn render_turn(
turn: &Turn,
t: &theme::Theme,
body: Style,
dim: Style,
rows: &mut Vec<Line<'static>>,
) {
let blank = || Line::from(Span::styled(String::new(), body));
match turn {
Turn::User(text) => {
rows.push(blank());
rows.push(Line::from(Span::styled(
"▸ you",
Style::default()
.fg(t.cyan)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)));
for l in text.lines() {
rows.push(Line::from(Span::styled(format!(" {l}"), body)));
}
}
Turn::Assistant(text) => {
rows.push(blank());
rows.push(Line::from(Span::styled(
"◆ claude",
Style::default()
.fg(t.purple)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
)));
rows.extend(md_preview::render_markdown(text));
}
Turn::Thinking(preview) => {
rows.push(Line::from(Span::styled(
format!(" 💭 {preview}"),
dim.add_modifier(Modifier::ITALIC),
)));
}
Turn::ToolUse { name, summary } => {
rows.push(Line::from(vec![
Span::styled(" ⚙ ", Style::default().fg(t.yellow).bg(t.bg_dark)),
Span::styled(
name.clone(),
Style::default()
.fg(t.yellow)
.bg(t.bg_dark)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {summary}"), dim),
]));
}
Turn::ToolResult(text) => {
rows.push(Line::from(Span::styled(format!(" → {text}"), dim)));
}
}
}