use super::super::theme::{accent, kind_style, label_style};
use super::markdown::markdown_spans_fenced;
use crate::interactive::tui::transcript::BlockKind;
use crate::interactive::tui::types::App;
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span, Text},
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
pub(in crate::interactive::tui) const SPINNER: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(in crate::interactive::tui) fn unlabelled_glyph(kind: BlockKind) -> Option<&'static str> {
match kind {
BlockKind::Error => Some("\u{2717} "),
BlockKind::System => Some("\u{b7} "),
BlockKind::Thinking => Some("\u{2248} "),
BlockKind::User | BlockKind::Assistant | BlockKind::Tool | BlockKind::Table => None,
}
}
pub(super) const DRAFT_LABEL: &str = "SAYA (draft)";
pub(in crate::interactive::tui) fn draw_transcript(frame: &mut Frame<'_>, app: &App, area: Rect) {
let text_width = area.width.saturating_sub(2);
app.viewport.set((text_width, area.height));
let width = text_width as usize;
let height = area.height as usize;
let mut lines: Vec<Line> = Vec::new();
let mut fence = false;
for (i, row) in app
.transcript
.wide_view(width, height, &app.wide_table)
.into_iter()
.enumerate()
{
if row.is_label {
let draft = row.kind == BlockKind::Assistant
&& app.request.stream.is_some()
&& is_live_assistant_label(&app.transcript, row.text.as_str(), i, width, height);
let text = if draft {
DRAFT_LABEL
} else {
row.text.as_str()
};
lines.push(Line::from(Span::styled(text.to_string(), label_style())));
continue;
}
let kind = row.kind;
let text = row.text;
if text.is_empty() {
lines.push(Line::from(""));
continue;
}
if kind == BlockKind::Assistant {
let mut spans = vec![Span::raw(" ")];
spans.extend(markdown_spans_fenced(&text, &mut fence));
lines.push(Line::from(spans));
continue;
}
fence = false;
let prefix = unlabelled_glyph(kind).unwrap_or(" ");
lines.push(Line::from(Span::styled(
format!("{prefix}{text}"),
kind_style(kind),
)));
}
frame.render_widget(Paragraph::new(Text::from(lines)), area);
let (total, first_visible) = app.transcript.scroll_metrics(width, height);
if total > height {
let mut state = ScrollbarState::new(total).position(first_visible);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.thumb_style(Style::default().fg(accent())),
area,
&mut state,
);
}
}
fn is_live_assistant_label(
transcript: &crate::interactive::tui::transcript::Transcript,
text: &str,
painted_idx: usize,
width: usize,
height: usize,
) -> bool {
use crate::interactive::tui::transcript::rows::label;
if text != label(BlockKind::Assistant).unwrap_or("SAYA") {
return false;
}
live_assistant_idx(transcript, width).is_some_and(|live| {
let (_, first_visible) = transcript.scroll_metrics(width, height);
live == first_visible.saturating_add(painted_idx)
})
}
fn live_assistant_idx(
transcript: &crate::interactive::tui::transcript::Transcript,
width: usize,
) -> Option<usize> {
use crate::interactive::tui::transcript::BlockKind;
let full = transcript.wrapped(width);
let open = full
.iter()
.rposition(|r| r.is_label && r.kind == BlockKind::User)?;
full.iter()
.rposition(|r| r.is_label && r.kind == BlockKind::Assistant)
.filter(|&idx| {
open < idx
&& !full[idx + 1..]
.iter()
.any(|r| r.is_label && r.kind == BlockKind::User)
})
}