spec-ai 0.8.4

A framework for building AI agents with structured outputs, policy enforcement, and execution tracking
Documentation
use crate::spec_ai_tui_app::handlers;
use crate::spec_ai_tui_app::models::ChatRole;
use crate::spec_ai_tui_app::state::{AppState, PanelFocus};
use crate::spec_ai_tui::{
    buffer::Buffer,
    geometry::Rect,
    layout::{Constraint, Layout},
    style::{Color, Line, MarkdownConfig, Span, Style, parse_markdown},
    widget::{
        StatefulWidget, Widget,
        builtin::{Block, Editor, SlashCommand, SlashMenu, StatusBar, StatusSection},
    },
};

pub fn render(state: &AppState, area: Rect, buf: &mut Buffer) {
    let layout = Layout::vertical()
        .constraints([
            Constraint::Fill(1),
            Constraint::Fixed(6),
            Constraint::Fixed(3),
            Constraint::Fixed(1),
        ])
        .split(area);

    render_chat(state, layout[0], buf);
    render_input(state, layout[1], buf);
    render_reasoning(state, layout[2], buf);
    render_status(state, layout[3], buf);
}

fn render_chat(state: &AppState, area: Rect, buf: &mut Buffer) {
    let border_style = if state.focus == PanelFocus::Chat {
        Style::new().fg(Color::Cyan)
    } else {
        Style::new().fg(Color::DarkGrey)
    };

    let title = match (&state.active_agent, &state.active_model) {
        (Some(agent), Some(model)) => format!("Conversation · Agent: {} · Model: {}", agent, model),
        (Some(agent), None) => format!("Conversation · Agent: {}", agent),
        (None, Some(model)) => format!("Conversation · Model: {}", model),
        (None, None) => "Conversation".to_string(),
    };

    let block = Block::bordered().title(title).border_style(border_style);
    Widget::render(&block, area, buf);

    let inner = block.inner(area);
    if inner.is_empty() {
        return;
    }

    let content_width = inner.width.saturating_sub(1) as usize;
    let mut lines: Vec<Line> = Vec::new();

    let md_config = MarkdownConfig::new()
        .max_width(content_width.saturating_sub(2))
        .wrap_prefix("  ");

    for (idx, message) in state.messages.iter().enumerate() {
        // Check if this is a streaming message that hasn't received content yet
        let is_waiting = state.is_streaming_message(idx) && message.content.is_empty();

        let (style, label) = if is_waiting {
            (Style::new().fg(Color::Yellow).bold(), "Working".to_string())
        } else {
            role_style(&message.role)
        };

        lines.push(Line::from_spans([
            Span::styled(
                format!("[{}] ", message.timestamp),
                Style::new().fg(Color::DarkGrey),
            ),
            Span::styled(label.to_string(), style),
        ]));

        // Parse markdown and add prefix to each line
        let parsed = parse_markdown(&message.content, &md_config);
        for md_line in parsed.lines {
            // Add indent prefix
            let mut prefixed_spans = vec![Span::raw("  ".to_string())];
            prefixed_spans.extend(md_line.spans);
            lines.push(Line::from_spans(prefixed_spans));
        }

        lines.push(Line::empty());
    }

    let visible_height = inner.height as usize;
    let total_lines = lines.len();
    let scroll = state.scroll_offset as usize;
    let start = if total_lines > visible_height + scroll {
        total_lines - visible_height - scroll
    } else {
        0
    };
    let end = (start + visible_height).min(total_lines);

    for (i, line) in lines[start..end].iter().enumerate() {
        let y = inner.y + i as u16;
        if y >= inner.bottom() {
            break;
        }
        buf.set_line(inner.x, y, line);
    }

    if total_lines > visible_height {
        let scrollbar_height = inner.height.saturating_sub(1);
        let thumb_pos = if total_lines > 0 {
            ((start as u32 * scrollbar_height as u32) / total_lines as u32) as u16
        } else {
            0
        };

        for y in 0..scrollbar_height {
            let char = if y == thumb_pos { "" } else { "" };
            buf.set_string(
                inner.right().saturating_sub(1),
                inner.y + y,
                char,
                Style::new().fg(Color::DarkGrey),
            );
        }
    }
}

fn render_input(state: &AppState, area: Rect, buf: &mut Buffer) {
    let border_style = if state.focus == PanelFocus::Input {
        Style::new().fg(Color::Cyan)
    } else {
        Style::new().fg(Color::DarkGrey)
    };

    let block = Block::bordered().title("Input").border_style(border_style);
    Widget::render(&block, area, buf);

    let inner = block.inner(area);
    if inner.is_empty() {
        return;
    }

    let help_text = if state.editor.show_slash_menu {
        "↑/↓: select | Enter: run"
    } else {
        "Ctrl+C: quit | Ctrl+L: clear | / commands | Alt+b/f: word nav"
    };
    buf.set_string(
        inner.x,
        inner.y,
        help_text,
        Style::new().fg(Color::DarkGrey),
    );

    buf.set_string(inner.x, inner.y + 1, "", Style::new().fg(Color::Green));

    let editor_height = inner.height.saturating_sub(1);
    let editor_area = Rect::new(
        inner.x + 2,
        inner.y + 1,
        inner.width.saturating_sub(2),
        editor_height,
    );
    let editor = Editor::new()
        .placeholder("Ask spec-ai or run /commands...")
        .style(Style::new().fg(Color::White));

    let mut editor_state = state.editor.clone();
    editor.render(editor_area, buf, &mut editor_state);

    if state.editor.show_slash_menu {
        let filtered_commands = handlers::slash_menu_commands(state);

        if !filtered_commands.is_empty() {
            let menu = SlashMenu::new()
                .commands(filtered_commands)
                .query(&state.editor.slash_query);

            let menu_area = Rect::new(
                inner.x + 2,
                area.y,
                inner.width.saturating_sub(2).min(50),
                area.height,
            );

            let mut menu_state = state.slash_menu.clone();
            menu.render(menu_area, buf, &mut menu_state);
        }
    }
}

fn render_reasoning(state: &AppState, area: Rect, buf: &mut Buffer) {
    let block = Block::bordered().title("Reasoning");
    Widget::render(&block, area, buf);

    let inner = block.inner(area);
    if inner.is_empty() {
        return;
    }

    let spinner_frames = ['', '', '', '', '', '', '', '', '', ''];
    let spinner = if state.busy {
        spinner_frames[(state.tick / 2) as usize % spinner_frames.len()]
    } else {
        ''
    };

    let entries = if state.reasoning.is_empty() {
        vec!["Waiting for backend...".to_string()]
    } else {
        state.reasoning.clone()
    };

    for (idx, line) in entries.iter().take(inner.height as usize).enumerate() {
        let prefix = if idx == 0 {
            format!("{spinner} ")
        } else {
            "  ".to_string()
        };
        let rendered = format!("{prefix}{line}");
        buf.set_string(
            inner.x,
            inner.y + idx as u16,
            &rendered,
            Style::new().fg(Color::White),
        );
    }
}

fn render_status(state: &AppState, area: Rect, buf: &mut Buffer) {
    let mut left_sections = vec![StatusSection::new(&state.status)];
    if let Some(err) = &state.error {
        left_sections
            .push(StatusSection::new(format!("Error: {}", err)).style(Style::new().fg(Color::Red)));
    }

    let center_sections = if state.busy {
        vec![StatusSection::new("Working").style(Style::new().fg(Color::Yellow))]
    } else {
        vec![StatusSection::new("Idle").style(Style::new().fg(Color::Green))]
    };

    let mut right_sections = vec![
        StatusSection::new("Tab: scroll/chat"),
        StatusSection::new("Ctrl+C: quit"),
    ];
    if let Some(model) = &state.active_model {
        right_sections.insert(0, StatusSection::new(format!("Model: {}", model)));
    }
    if let Some(usage) = &state.token_usage {
        right_sections.insert(
            0,
            StatusSection::new(format!(
                "Tokens: {} (P: {}, C: {})",
                usage.total_tokens, usage.prompt_tokens, usage.completion_tokens
            ))
            .style(Style::new().fg(Color::Cyan)),
        );
    }
    if !state.pending_images.is_empty() {
        right_sections.insert(
            0,
            StatusSection::new(format!("Imgs: {}", state.pending_images.len())),
        );
    }

    let bar = StatusBar::new()
        .left(left_sections)
        .center(center_sections)
        .right(right_sections)
        .style(Style::new().bg(Color::DarkGrey).fg(Color::White));

    Widget::render(&bar, area, buf);
}

fn role_style(role: &ChatRole) -> (Style, String) {
    match role {
        ChatRole::User => (Style::new().fg(Color::Green).bold(), role.label()),
        ChatRole::Assistant => (Style::new().fg(Color::Cyan).bold(), role.label()),
        ChatRole::System => (Style::new().fg(Color::Yellow).bold(), role.label()),
        ChatRole::Agent(_) => (Style::new().fg(Color::Magenta).bold(), role.label()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::spec_ai_tui_app::state::AppState;
    use crate::spec_ai_tui::buffer::Buffer;
    use crate::spec_ai_tui::style::Modifier;

    #[test]
    fn role_style_user_returns_green() {
        let (style, label) = role_style(&ChatRole::User);
        assert_eq!(style.fg, Color::Green);
        assert_eq!(label, "User");
    }

    #[test]
    fn role_style_assistant_returns_cyan() {
        let (style, label) = role_style(&ChatRole::Assistant);
        assert_eq!(style.fg, Color::Cyan);
        assert_eq!(label, "Assistant");
    }

    #[test]
    fn role_style_system_returns_yellow() {
        let (style, label) = role_style(&ChatRole::System);
        assert_eq!(style.fg, Color::Yellow);
        assert_eq!(label, "System");
    }

    #[test]
    fn role_style_agent_returns_magenta() {
        let (style, label) = role_style(&ChatRole::Agent("test".to_string()));
        assert_eq!(style.fg, Color::Magenta);
        assert_eq!(label, "Agent test");
    }

    #[test]
    fn role_style_all_are_bold() {
        let roles = [
            ChatRole::User,
            ChatRole::Assistant,
            ChatRole::System,
            ChatRole::Agent("x".to_string()),
        ];
        for role in &roles {
            let (style, _) = role_style(role);
            assert!(
                style.modifier.contains(Modifier::BOLD),
                "Style for {:?} should be bold",
                role
            );
        }
    }

    #[test]
    fn render_includes_active_model_label() {
        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
        let mut state = AppState::new(rx);
        state.active_agent = Some("default".to_string());
        state.active_model = Some("openai/gpt-5".to_string());
        state.busy = false;
        state.status = "Status: awaiting input".to_string();

        let area = Rect::new(0, 0, 100, 20);
        let mut buf = Buffer::new(area);
        render(&state, area, &mut buf);

        let mut rendered = String::new();
        for y in area.y..area.bottom() {
            for x in area.x..area.right() {
                if let Some(cell) = buf.get(x, y) {
                    rendered.push_str(&cell.symbol);
                }
            }
            rendered.push('\n');
        }

        assert!(rendered.contains("Model: openai/gpt-5"));
    }
}