mod context;
mod reasoning;
mod text;
mod tool;
pub use context::ContextBlock;
pub use reasoning::{ReasoningBlock, ReasoningMessage};
pub use text::{SteeredPrompt, SystemText, TextBlock, TextMessage, UserPrompt};
pub use tool::{ToolBlock, ToolMessage};
use std::collections::BTreeMap;
use ratatui::prelude::*;
use shuvarie_core::DiagnosticInfo;
use super::segment::Segment;
use super::virtualizer::TurnEst;
use crate::tui::{spinner, theme};
pub(super) fn format_duration_ms(ms: u64) -> String {
if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
let total = ms / 1000;
format!("{}m {:02}s", total / 60, total % 60)
}
}
pub(super) fn shows_elapsed(name: &str) -> bool {
name == "run_shell" || name == "explore_workspace"
}
pub(super) fn hides_output_when_collapsed(name: &str) -> bool {
name == "read_file" || name == "list_dir"
}
pub enum Block {
User(UserPrompt),
Steered(SteeredPrompt),
Text(TextBlock),
System(SystemText),
Tool(Box<ToolBlock>),
Reasoning(ReasoningBlock),
Context(ContextBlock),
Summary,
Interrupted,
Working,
ToolOnlyNote,
}
pub enum BlockMessage {
Toggle,
Tool(ToolMessage),
Text(TextMessage),
Reasoning(ReasoningMessage),
}
pub struct ChatEnv<'a> {
pub lsp_diagnostics: &'a BTreeMap<String, Vec<DiagnosticInfo>>,
pub rev: u64,
}
impl Block {
pub fn update(&mut self, msg: BlockMessage) -> bool {
match msg {
BlockMessage::Toggle => match self {
Block::Tool(tool) => {
tool.toggle();
true
}
Block::Reasoning(reasoning) => {
reasoning.toggle();
true
}
_ => false,
},
BlockMessage::Tool(msg) => match self {
Block::Tool(tool) => tool.update(msg),
_ => false,
},
BlockMessage::Text(msg) => match self {
Block::Text(text) => text.update(msg),
_ => false,
},
BlockMessage::Reasoning(msg) => match self {
Block::Reasoning(reasoning) => reasoning.update(msg),
_ => false,
},
}
}
pub fn est(&self) -> TurnEst {
match self {
Block::User(block) => block.est(),
Block::Steered(block) => block.est(),
Block::Text(block) => block.est(),
Block::System(block) => block.est(),
Block::Tool(block) => block.est(),
Block::Reasoning(block) => block.est(),
Block::Context(block) => block.est(),
Block::Summary | Block::Interrupted | Block::Working | Block::ToolOnlyNote => {
TurnEst::deco(1)
}
}
}
pub fn view(&self, width: u16, env: &ChatEnv) -> Vec<Segment> {
match self {
Block::User(block) => block.view(width),
Block::Steered(block) => block.view(width),
Block::Text(block) => block.view(width),
Block::System(block) => block.view(width),
Block::Tool(block) => vec![block.view(width, env)],
Block::Reasoning(block) => block.view(width),
Block::Context(block) => block.view(),
Block::Summary => vec![Segment::plain(vec![Line::from(
Span::raw("◈ summary of earlier conversation")
.fg(theme::accent())
.italic(),
)])],
Block::Interrupted => vec![Segment::plain(vec![Line::from(
Span::raw("(interrupted)").fg(theme::warning()).italic(),
)])],
Block::Working => vec![Segment::plain(vec![Line::from(vec![
spinner::spinner(),
Span::raw(" "),
Span::raw("(Working...)").fg(theme::text_muted()),
])])],
Block::ToolOnlyNote => vec![Segment::plain(vec![Line::from(
Span::raw("(tool output only — no text reply)").fg(theme::text_muted()),
)])],
}
}
pub fn is_tool(&self) -> bool {
matches!(self, Block::Tool(_))
}
pub fn is_text(&self) -> bool {
matches!(self, Block::Text(_))
}
pub fn tool_matches(&self, name: &str, worker: &Option<String>) -> bool {
matches!(self, Block::Tool(tool) if tool.matches(name, worker))
}
pub fn tool_call_id(&self) -> Option<&str> {
match self {
Block::Tool(tool) => tool.call_id(),
_ => None,
}
}
pub fn tool_is_running(&self) -> bool {
matches!(self, Block::Tool(tool) if tool.is_running())
}
pub fn spinner_bearing(&self) -> bool {
match self {
Block::Reasoning(reasoning) => reasoning.is_thinking(),
Block::Tool(tool) => tool.is_running(),
_ => false,
}
}
pub fn is_thinking(&self) -> bool {
matches!(self, Block::Reasoning(reasoning) if reasoning.is_thinking())
}
pub fn set_expanded(&mut self, expanded: bool) {
match self {
Block::Tool(tool) => tool.set_expanded(expanded),
Block::Reasoning(reasoning) => reasoning.set_expanded(expanded),
_ => {}
}
}
pub fn is_expanded(&self) -> bool {
match self {
Block::Tool(tool) => tool.is_expanded(),
Block::Reasoning(reasoning) => reasoning.is_expanded(),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::format_duration_ms;
#[test]
fn duration_formats_seconds_and_minutes() {
assert_eq!(format_duration_ms(0), "0.0s");
assert_eq!(format_duration_ms(4500), "4.5s");
assert_eq!(format_duration_ms(10_300), "10.3s");
assert_eq!(format_duration_ms(59_900), "59.9s");
assert_eq!(format_duration_ms(60_000), "1m 00s");
assert_eq!(format_duration_ms(65_400), "1m 05s");
assert_eq!(format_duration_ms(133_000), "2m 13s");
}
}