Conversation Customization

The conversation view is the central UI element that displays the chat history between the user and the agent. Agent Air provides extensive customization options through the conversation factory pattern, which lets you configure how new conversations are created, styled, and initialized. Welcome screens, custom titles, and branded interfaces are all possible through these APIs.

The factory pattern decouples conversation creation from the rest of the application. When sessions are created or cleared, the factory produces a fresh ConversationView instance with your configured settings. This ensures consistent branding and behavior across all conversation instances.


ConversationViewFactory

The factory is a function that creates new ConversationView instances. It’s called when sessions are created or cleared.

pub type ConversationViewFactory = Box<dyn Fn() -> Box<dyn ConversationView> + Send + Sync>;

A factory is a boxed closure that:

  • Takes no arguments
  • Returns a boxed ConversationView
  • Is Send + Sync for thread safety

Setting the Factory

Use set_conversation_factory() on AgentAir:

impl AgentAir {
    pub fn set_conversation_factory<F>(&mut self, factory: F) -> &mut Self
    where
        F: Fn() -> Box<dyn ConversationView> + Send + Sync + 'static;
}

Example:

use agent_air::AgentAir;
use agent_air::tui::widgets::ChatView;

let mut agent = AgentAir::new(&MyConfig)?;

agent.set_conversation_factory(|| {
    Box::new(ChatView::new().with_title("My Agent"))
});

Default Factory

If no factory is set, the App creates a default ChatView:

// Equivalent to the default behavior
agent.set_conversation_factory(|| {
    Box::new(ChatView::new())
});

ConversationView Trait

The factory returns types implementing ConversationView. This trait defines the interface for displaying and managing conversation content.

pub trait ConversationView: Send + 'static {
    // Message operations
    fn add_user_message(&mut self, content: String);
    fn add_assistant_message(&mut self, content: String);
    fn add_system_message(&mut self, content: String);

    // Streaming
    fn append_streaming(&mut self, text: &str);
    fn complete_streaming(&mut self);
    fn discard_streaming(&mut self);
    fn is_streaming(&self) -> bool;

    // Tool messages
    fn add_tool_message(&mut self, tool_use_id: &str, display_name: &str, display_title: &str);
    fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus);

    // Scrolling
    fn scroll_up(&mut self);
    fn scroll_down(&mut self);
    fn enable_auto_scroll(&mut self);

    // Rendering
    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, pending_status: Option<&str>);

    // Animation
    fn step_spinner(&mut self);

    // Session state
    fn save_state(&self) -> Box<dyn Any + Send>;
    fn restore_state(&mut self, state: Box<dyn Any + Send>);
    fn clear(&mut self);
}

Customizing ChatView

The factory is typically used to configure ChatView, the built-in conversation implementation. ChatView supports titles, custom prefixes, and initial content.

use agent_air::tui::widgets::{ChatView, ChatViewConfig};

agent.set_conversation_factory(|| {
    Box::new(
        ChatView::with_config(ChatViewConfig::new()
            .with_user_prefix("You: ")
            .with_empty_message("Start a conversation...")
        )
        .with_title("Assistant")
    )
});

ChatViewConfig Options

pub struct ChatViewConfig {
    pub user_prefix: String,
    pub assistant_prefix: String,
    pub system_prefix: String,
    pub default_title: String,
    pub empty_message: String,
}

impl ChatViewConfig {
    pub fn new() -> Self;
    pub fn with_user_prefix(self, prefix: &str) -> Self;
    pub fn with_assistant_prefix(self, prefix: &str) -> Self;
    pub fn with_system_prefix(self, prefix: &str) -> Self;
    pub fn with_default_title(self, title: &str) -> Self;
    pub fn with_empty_message(self, msg: &str) -> Self;
}

Welcome Screens

Welcome screens are custom content displayed when the chat is empty. They provide branding, instructions, or getting-started information before the user sends their first message. Welcome screens are implemented as render functions that draw content when there are no messages.

RenderFn Type

Welcome screens use the RenderFn type:

pub type RenderFn = Box<dyn Fn(&mut Frame, Rect, &Theme) + Send + Sync>;

The function receives:

  • &mut Frame - The ratatui frame for rendering
  • Rect - The area to render within
  • &Theme - The current theme for styling

with_initial_content Method

Set a welcome screen using with_initial_content() on ChatView:

impl ChatView {
    pub fn with_initial_content(mut self, render: RenderFn) -> Self;
}

Basic Welcome Screen

A simple centered welcome message:

use agent_air::tui::widgets::{ChatView, RenderFn};
use ratatui::{
    layout::Alignment,
    text::{Line, Span},
    widgets::Paragraph,
};

let welcome: RenderFn = Box::new(|frame, area, theme| {
    let lines = vec![
        Line::from(""),
        Line::from("Welcome to MyAgent"),
        Line::from(""),
        Line::from("Type a message to get started."),
    ];

    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Center);

    frame.render_widget(paragraph, area);
});

let chat = ChatView::new()
    .with_initial_content(welcome);

Styled Welcome Screen

Apply theme colors and text styles:

use ratatui::style::{Color, Modifier, Style};

let welcome: RenderFn = Box::new(|frame, area, theme| {
    let title_style = Style::default()
        .fg(Color::Cyan)
        .add_modifier(Modifier::BOLD);

    let subtitle_style = Style::default()
        .fg(Color::DarkGray);

    let lines = vec![
        Line::from(""),
        Line::from(""),
        Line::from(Span::styled("MyAgent", title_style)),
        Line::from(Span::styled("v1.0.0", subtitle_style)),
        Line::from(""),
        Line::from("An AI-powered assistant"),
        Line::from(""),
        Line::from(Span::styled(
            "Type /help for available commands",
            subtitle_style
        )),
    ];

    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Center);

    frame.render_widget(paragraph, area);
});

Using Theme Colors

Access theme colors for consistent styling:

let welcome: RenderFn = Box::new(|frame, area, theme| {
    let lines = vec![
        Line::from(""),
        Line::from(vec![
            Span::styled("My", theme.user_prefix),
            Span::styled("Agent", theme.assistant_text),
        ]),
        Line::from(""),
        Line::from(Span::styled(
            "Ready to help.",
            theme.system_prefix,
        )),
    ];

    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Center);

    frame.render_widget(paragraph, area);
});

ASCII Art Welcome

Display ASCII art or logos:

let welcome: RenderFn = Box::new(|frame, area, theme| {
    let logo = r#"
    __  __         _                    _
   |  \/  |_   _  / \   __ _  ___ _ __ | |_
   | |\/| | | | |/ _ \ / _` |/ _ \ '_ \| __|
   | |  | | |_| / ___ \ (_| |  __/ | | | |_
   |_|  |_|\__, /_/   \_\__, |\___|_| |_|\__|
           |___/        |___/
    "#;

    let lines: Vec<Line> = logo
        .lines()
        .map(|l| Line::from(Span::styled(l, Style::default().fg(Color::Cyan))))
        .collect();

    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Center);

    frame.render_widget(paragraph, area);
});

Dynamic Content

Include dynamic information like time-based greetings:

use chrono::Local;

let welcome: RenderFn = Box::new(|frame, area, theme| {
    let now = Local::now();
    let greeting = match now.hour() {
        5..=11 => "Good morning",
        12..=17 => "Good afternoon",
        _ => "Good evening",
    };

    let lines = vec![
        Line::from(""),
        Line::from(format!("{}, welcome to MyAgent!", greeting)),
        Line::from(""),
        Line::from(format!("Today is {}", now.format("%A, %B %d"))),
    ];

    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Center);

    frame.render_widget(paragraph, area);
});

Custom Title Rendering

Customize the title bar with with_title_renderer():

pub type TitleRenderFn = Box<dyn Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync>;

impl ChatView {
    pub fn with_title_renderer<F>(mut self, render: F) -> Self
    where
        F: Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync + 'static;
}

The renderer returns a tuple of (left_line, right_line) for the title bar:

let chat = ChatView::new()
    .with_title("MyAgent")
    .with_title_renderer(|title, theme| {
        let left = Line::from(vec![
            Span::styled("// ", Style::default().fg(Color::Red)),
            Span::styled(title, Style::default().bold()),
        ]);

        let right = Line::from(Span::styled(
            "v1.0.0",
            Style::default().fg(Color::DarkGray),
        ));

        (left, right)
    });

Session State Preservation

The ConversationView trait includes methods for session switching. The factory is called when clear() needs a fresh view or when the App needs a new conversation instance.

// Save current state before switching
let state = conversation.save_state();

// Restore when switching back
conversation.restore_state(state);

// Clear for new session
conversation.clear();

Custom ConversationView

For complete control, implement ConversationView directly:

use agent_air::tui::widgets::{ConversationView, ToolStatus};
use ratatui::{layout::Rect, Frame};
use std::any::Any;

struct CustomChatView {
    messages: Vec<(String, String)>,  // (role, content)
    streaming: Option<String>,
}

impl ConversationView for CustomChatView {
    fn add_user_message(&mut self, content: String) {
        self.messages.push(("user".into(), content));
    }

    fn add_assistant_message(&mut self, content: String) {
        self.messages.push(("assistant".into(), content));
    }

    fn add_system_message(&mut self, content: String) {
        self.messages.push(("system".into(), content));
    }

    fn append_streaming(&mut self, text: &str) {
        if let Some(ref mut buffer) = self.streaming {
            buffer.push_str(text);
        } else {
            self.streaming = Some(text.to_string());
        }
    }

    fn complete_streaming(&mut self) {
        if let Some(content) = self.streaming.take() {
            self.add_assistant_message(content);
        }
    }

    fn discard_streaming(&mut self) {
        self.streaming = None;
    }

    fn is_streaming(&self) -> bool {
        self.streaming.is_some()
    }

    fn add_tool_message(&mut self, _id: &str, name: &str, title: &str) {
        self.messages.push(("tool".into(), format!("{}: {}", name, title)));
    }

    fn update_tool_status(&mut self, _id: &str, _status: ToolStatus) {}

    fn scroll_up(&mut self) {}
    fn scroll_down(&mut self) {}
    fn enable_auto_scroll(&mut self) {}

    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, _pending: Option<&str>) {
        // Custom rendering
    }

    fn step_spinner(&mut self) {}

    fn save_state(&self) -> Box<dyn Any + Send> {
        Box::new(self.messages.clone())
    }

    fn restore_state(&mut self, state: Box<dyn Any + Send>) {
        if let Ok(messages) = state.downcast::<Vec<(String, String)>>() {
            self.messages = *messages;
        }
    }

    fn clear(&mut self) {
        self.messages.clear();
        self.streaming = None;
    }
}

// Use in factory
agent.set_conversation_factory(|| {
    Box::new(CustomChatView {
        messages: Vec::new(),
        streaming: None,
    })
});

Complete Example

A branded agent with custom welcome screen and title rendering:

use agent_air::AgentAir;
use agent_air::tui::widgets::{ChatView, ChatViewConfig, RenderFn};
use agent_air::tui::themes::Theme;
use ratatui::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
    Frame,
};

fn create_branded_welcome() -> RenderFn {
    Box::new(|frame: &mut Frame, area: Rect, theme: &Theme| {
        // Center the content vertically
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Percentage(30),
                Constraint::Min(10),
                Constraint::Percentage(30),
            ])
            .split(area);

        let center = chunks[1];

        let title_style = Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD);

        let version_style = Style::default()
            .fg(Color::DarkGray);

        let command_style = Style::default()
            .fg(Color::Yellow);

        let lines = vec![
            Line::from(vec![
                Span::styled("Dev", title_style),
                Span::styled("Agent", Style::default().fg(Color::Green).bold()),
            ]),
            Line::from(Span::styled("v0.1.0", version_style)),
            Line::from(""),
            Line::from("Your AI-powered development assistant"),
            Line::from(""),
            Line::from(vec![
                Span::raw("Commands: "),
                Span::styled("/help", command_style),
                Span::raw(" | "),
                Span::styled("/clear", command_style),
                Span::raw(" | "),
                Span::styled("/themes", command_style),
            ]),
            Line::from(""),
            Line::from(Span::styled(
                "Press Enter to send a message",
                version_style,
            )),
        ];

        let paragraph = Paragraph::new(lines)
            .alignment(Alignment::Center);

        frame.render_widget(paragraph, center);
    })
}

fn main() -> std::io::Result<()> {
    let mut agent = AgentAir::new(&MyConfig)?;

    agent.set_conversation_factory(|| {
        Box::new(
            ChatView::with_config(
                ChatViewConfig::new()
                    .with_default_title("DevAgent")
                    .with_empty_message("")  // Use custom welcome instead
            )
            .with_title("DevAgent")
            .with_initial_content(create_branded_welcome())
            .with_title_renderer(|title, _theme| {
                let left = Line::from(vec![
                    Span::styled("// ", Style::default().fg(Color::Red)),
                    Span::styled(title, Style::default().bold()),
                ]);
                let right = Line::from("");
                (left, right)
            })
        )
    });

    agent.run()
}