shuvarie 0.1.1

Blazingly fast AI coding TUI for chivalrous people
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};

/// Format a duration in milliseconds for block display: seconds with one
/// decimal below a minute, `Xm YYs` at or above it.
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)
    }
}

/// Tool blocks that carry the elapsed/taken meta row: only the long-running
/// calls where timing is worth the extra row. Thinking blocks time themselves
/// in the reasoning header.
pub(super) fn shows_elapsed(name: &str) -> bool {
    name == "run_shell" || name == "explore_workspace"
}

/// Tool blocks whose successful output is not previewed when collapsed: file
/// contents and directory listings the header already summarizes. Failed
/// calls keep their (short) error message visible.
pub(super) fn hides_output_when_collapsed(name: &str) -> bool {
    name == "read_file" || name == "list_dir"
}

/// One chat block: a TEA model per variant for the stateful kinds, unit
/// variants for the stateless decorations the engine synthesizes around turns.
pub enum Block {
    User(UserPrompt),
    Steered(SteeredPrompt),
    Text(TextBlock),
    System(SystemText),
    Tool(Box<ToolBlock>),
    Reasoning(ReasoningBlock),
    Context(ContextBlock),
    Summary,
    Interrupted,
    Working,
    ToolOnlyNote,
}

/// Messages routed into a block, either from the chat's data flow or from a
/// click hit-test (`Toggle`).
pub enum BlockMessage {
    Toggle,
    Tool(ToolMessage),
    Text(TextMessage),
    Reasoning(ReasoningMessage),
}

/// Read-only view context handed to block `view` calls.
pub struct ChatEnv<'a> {
    pub lsp_diagnostics: &'a BTreeMap<String, Vec<DiagnosticInfo>>,
    /// Revision of the environment the diagnostics come from, so blocks can
    /// key environment-dependent caches (e.g. cached tool bodies) on it.
    pub rev: u64,
}

impl Block {
    /// Apply a message to the block, returning whether its state changed.
    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,
            },
        }
    }

    /// Estimated row counters contributed by this block, mirroring what
    /// [`Block::view`] will produce (width-independent; the virtualizer folds
    /// them into an O(1) height estimate per width).
    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)
            }
        }
    }

    /// Project the block into renderable segments (without hit addresses —
    /// the chat engine stamps those by turn/block position).
    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())
    }

    /// Whether the block's first segment animates per spinner frame: a
    /// thinking reasoning header or a running tool header.
    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");
    }
}