use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::widget::theme::LIGHT_GRAY;
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TypingStyle {
None,
#[default]
Character,
Word,
Line,
Chunk,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StreamCursor {
#[default]
Block,
Underline,
Bar,
None,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StreamStatus {
#[default]
Idle,
Streaming,
Paused,
Complete,
Error,
}
pub struct AiStream {
content: String,
visible_chars: usize,
typing_style: TypingStyle,
cursor: StreamCursor,
typing_speed: u64,
last_update: Option<Instant>,
status: StreamStatus,
fg: Color,
bg: Option<Color>,
cursor_color: Color,
show_thinking: bool,
thinking_frame: usize,
wrap: bool,
scroll: usize,
render_markdown: bool,
props: WidgetProps,
}
impl AiStream {
pub fn new() -> Self {
Self {
content: String::new(),
visible_chars: 0,
typing_style: TypingStyle::default(),
cursor: StreamCursor::default(),
typing_speed: 30,
last_update: None,
status: StreamStatus::Idle,
fg: Color::WHITE,
bg: None,
cursor_color: Color::rgb(100, 200, 255),
show_thinking: true,
thinking_frame: 0,
wrap: true,
scroll: 0,
render_markdown: true,
props: WidgetProps::new(),
}
}
pub fn typing_style(mut self, style: TypingStyle) -> Self {
self.typing_style = style;
self
}
pub fn typing_speed(mut self, ms: u64) -> Self {
self.typing_speed = ms;
self
}
pub fn cursor(mut self, cursor: StreamCursor) -> Self {
self.cursor = cursor;
self
}
pub fn fg(mut self, color: Color) -> Self {
self.fg = color;
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
pub fn cursor_color(mut self, color: Color) -> Self {
self.cursor_color = color;
self
}
pub fn thinking(mut self, show: bool) -> Self {
self.show_thinking = show;
self
}
pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
pub fn markdown(mut self, enable: bool) -> Self {
self.render_markdown = enable;
self
}
pub fn content(mut self, text: impl Into<String>) -> Self {
self.content = text.into();
self.visible_chars = 0;
self.status = StreamStatus::Streaming;
self.last_update = Some(Instant::now());
self
}
pub fn append(&mut self, text: &str) {
self.content.push_str(text);
if self.status == StreamStatus::Idle {
self.status = StreamStatus::Streaming;
self.last_update = Some(Instant::now());
}
}
pub fn set_content(&mut self, text: impl Into<String>) {
self.content = text.into();
self.visible_chars = self.content.chars().count();
self.status = StreamStatus::Complete;
}
pub fn clear(&mut self) {
self.content.clear();
self.visible_chars = 0;
self.status = StreamStatus::Idle;
self.scroll = 0;
}
pub fn complete(&mut self) {
self.status = StreamStatus::Complete;
self.visible_chars = self.content.chars().count();
}
pub fn error(&mut self) {
self.status = StreamStatus::Error;
}
pub fn pause(&mut self) {
if self.status == StreamStatus::Streaming {
self.status = StreamStatus::Paused;
}
}
pub fn resume(&mut self) {
if self.status == StreamStatus::Paused {
self.status = StreamStatus::Streaming;
self.last_update = Some(Instant::now());
}
}
pub fn tick(&mut self) {
self.thinking_frame = (self.thinking_frame + 1) % 4;
if self.status != StreamStatus::Streaming {
return;
}
if self.typing_style == TypingStyle::None {
self.visible_chars = self.content.chars().count();
self.status = StreamStatus::Complete;
return;
}
let now = Instant::now();
let elapsed = self
.last_update
.map(|t| now.duration_since(t))
.unwrap_or(Duration::ZERO);
if elapsed.as_millis() >= self.typing_speed as u128 {
self.last_update = Some(now);
let total_chars = self.content.chars().count();
match self.typing_style {
TypingStyle::Character => {
if self.visible_chars < total_chars {
self.visible_chars += 1;
} else {
self.status = StreamStatus::Complete;
}
}
TypingStyle::Word => {
let chars: Vec<char> = self.content.chars().collect();
let mut pos = self.visible_chars;
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
self.visible_chars = pos;
if pos >= total_chars {
self.status = StreamStatus::Complete;
}
}
TypingStyle::Line => {
let chars: Vec<char> = self.content.chars().collect();
let mut pos = self.visible_chars;
while pos < chars.len() && chars[pos] != '\n' {
pos += 1;
}
if pos < chars.len() {
pos += 1; }
self.visible_chars = pos;
if pos >= total_chars {
self.status = StreamStatus::Complete;
}
}
TypingStyle::Chunk => {
self.visible_chars = (self.visible_chars + 5).min(total_chars);
if self.visible_chars >= total_chars {
self.status = StreamStatus::Complete;
}
}
TypingStyle::None => {}
}
}
}
fn visible_text(&self) -> String {
self.content.chars().take(self.visible_chars).collect()
}
pub fn status(&self) -> StreamStatus {
self.status
}
pub fn is_complete(&self) -> bool {
self.status == StreamStatus::Complete
}
pub fn progress(&self) -> f32 {
let total = self.content.chars().count();
if total == 0 {
return 1.0;
}
self.visible_chars as f32 / total as f32
}
pub fn scroll_down(&mut self, amount: usize) {
self.scroll = self.scroll.saturating_add(amount);
}
pub fn scroll_up(&mut self, amount: usize) {
self.scroll = self.scroll.saturating_sub(amount);
}
fn render_thinking(&self, ctx: &mut RenderContext) {
let indicators = ['⠋', '⠙', '⠹', '⠸'];
let ch = indicators[self.thinking_frame % indicators.len()];
let mut cell = Cell::new(ch);
cell.fg = Some(self.cursor_color);
ctx.set(0, 0, cell);
let text = " Thinking...";
for (i, c) in text.chars().enumerate() {
let mut cell = Cell::new(c);
cell.fg = Some(LIGHT_GRAY);
cell.modifier = Modifier::ITALIC;
ctx.set(1 + i as u16, 0, cell);
}
}
}
impl Default for AiStream {
fn default() -> Self {
Self::new()
}
}
impl View for AiStream {
crate::impl_view_meta!("AiStream");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if area.width == 0 || area.height == 0 {
return;
}
if self.content.is_empty() && self.show_thinking && self.status == StreamStatus::Streaming {
self.render_thinking(ctx);
return;
}
let visible = self.visible_text();
let _width = area.width as usize;
let mut x = 0u16;
let mut y = 0u16;
for ch in visible.chars() {
if ch == '\n' {
x = 0;
y += 1;
continue;
}
if self.wrap && x >= area.width {
x = 0;
y += 1;
}
if y >= area.height {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.fg);
cell.bg = self.bg;
ctx.set(x, y, cell);
x += 1;
}
if self.status == StreamStatus::Streaming
&& self.cursor != StreamCursor::None
&& y < area.height
{
let cursor_char = match self.cursor {
StreamCursor::Block => '█',
StreamCursor::Underline => '_',
StreamCursor::Bar => '│',
StreamCursor::None => ' ',
};
let mut cell = Cell::new(cursor_char);
cell.fg = Some(self.cursor_color);
if self.thinking_frame.is_multiple_of(2) {
ctx.set(x, y, cell);
}
}
}
}
impl_styled_view!(AiStream);
impl_props_builders!(AiStream);
pub fn ai_stream() -> AiStream {
AiStream::new()
}
pub fn ai_response(content: impl Into<String>) -> AiStream {
AiStream::new().content(content)
}