use super::markdown::markdown_spans_fenced;
use super::theme::{accent, kind_style, rail_style, secondary, warning};
use crate::interactive::tui::transcript::BlockKind;
use crate::interactive::tui::types::App;
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
};
pub(super) const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(super) 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 (kind, text) in app.transcript.view(width, height) {
if text.is_empty() {
lines.push(Line::from(""));
continue;
}
let glyph = match kind {
BlockKind::User => "❯ ",
BlockKind::Assistant => "◆ ",
BlockKind::Tool => "▸ ",
BlockKind::Error => "✗ ",
BlockKind::System => "· ",
};
let rail = Span::styled(glyph, rail_style(kind));
let mut spans = vec![rail];
if kind == BlockKind::Assistant {
spans.extend(markdown_spans_fenced(&text, &mut fence));
} else {
fence = false;
spans.push(Span::styled(text, kind_style(kind)));
}
lines.push(Line::from(spans));
}
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,
);
}
}
pub(super) fn draw_empty_state(frame: &mut Frame<'_>, app: &App, area: Rect) {
let mut content = Vec::with_capacity(11);
content.push(Line::from(Span::styled(
"◆ saya",
Style::default().fg(accent()).add_modifier(Modifier::BOLD),
)));
content.push(Line::from(Span::styled(
"Ask your databases in plain language.",
Style::default().fg(secondary()),
)));
content.push(Line::from(""));
if app.profiles.is_empty() {
content.push(Line::from(Span::styled(
"No database is configured yet.",
Style::default().fg(warning()).add_modifier(Modifier::BOLD),
)));
content.push(Line::from(Span::styled(
"Run `saya config init`, add a profile to .saya/connections.toml,",
Style::default().fg(secondary()),
)));
content.push(Line::from(Span::styled(
"then run `saya connection test <name>` and restart.",
Style::default().fg(secondary()),
)));
content.push(Line::from(""));
content.push(Line::from(Span::styled(
"Check problems any time with `saya config doctor`.",
Style::default().fg(secondary()),
)));
} else {
let mut spans = vec![Span::styled(
"databases ",
Style::default().fg(secondary()),
)];
for (i, profile) in app.profiles.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" · ", Style::default().fg(secondary())));
}
spans.push(Span::styled(
profile.as_str(),
Style::default().fg(accent()),
));
}
content.push(Line::from(spans));
}
content.push(Line::from(""));
content.push(Line::from(Span::styled(
"try asking",
Style::default().fg(secondary()),
)));
content.push(Line::from(Span::styled(
" which tables track billing?",
Style::default()
.fg(secondary())
.add_modifier(Modifier::ITALIC),
)));
content.push(Line::from(Span::styled(
" top 5 customers by revenue",
Style::default()
.fg(secondary())
.add_modifier(Modifier::ITALIC),
)));
content.push(Line::from(Span::styled(
" compare row counts across the connected databases",
Style::default()
.fg(secondary())
.add_modifier(Modifier::ITALIC),
)));
content.push(Line::from(""));
content.push(Line::from(Span::styled(
"/ commands @ tables ? help Ctrl+C quit",
Style::default().fg(secondary()),
)));
let content_len = content.len();
let pad = (area.height as usize).saturating_sub(content_len) / 2;
let mut lines = Vec::with_capacity(pad + content_len);
for _ in 0..pad {
lines.push(Line::from(""));
}
lines.extend(content);
frame.render_widget(
Paragraph::new(Text::from(lines)).alignment(Alignment::Center),
area,
);
}
pub(super) fn approval_height(detail: Option<&str>, width: u16) -> u16 {
let inner = width.saturating_sub(2).max(1) as usize;
if let Some(d) = detail {
let wrapped_lines: usize = d
.lines()
.map(|line| line.chars().count().max(1).div_ceil(inner))
.sum();
(6 + wrapped_lines as u16).min(16)
} else {
5
}
}
pub(super) fn draw_approval(frame: &mut Frame<'_>, tool: &str, detail: Option<&str>, area: Rect) {
let mut lines = Vec::new();
if let Some(sql) = detail {
lines.push(Line::from(Span::styled(
"Approve this read-only query:",
Style::default().add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
for l in sql.lines() {
lines.push(Line::from(Span::styled(
l.to_string(),
Style::default().fg(accent()),
)));
}
} else {
lines.push(Line::from(format!("Run tool `{tool}`?")));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"[y] allow [n] deny",
Style::default().add_modifier(Modifier::BOLD),
)));
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(super::theme::warning()))
.title(Span::styled(
" approval required ",
Style::default()
.fg(super::theme::warning())
.add_modifier(Modifier::BOLD),
));
frame.render_widget(
Paragraph::new(Text::from(lines))
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}