217 lines
7.9 KiB
Rust
217 lines
7.9 KiB
Rust
//! An agent block in the TUI transcript: one exchange rendered as the user's
|
|
//! submitted input followed by the agent's response.
|
|
use std::rc::Rc;
|
|
|
|
use warp::tui_export::{
|
|
AIAgentExchangeId, AIAgentTextSection, AIBlockModel, AIConversationId, Appearance,
|
|
};
|
|
use warp_core::ui::color::blend::Blend;
|
|
// `ThemeFill` is the theme-layer color (it supports blend/opacity); `Fill` below
|
|
// is the element-layer color it converts into on its way to a terminal cell.
|
|
use warp_core::ui::theme::Fill as ThemeFill;
|
|
use warpui::SingletonEntity;
|
|
use warpui_core::elements::tui::{
|
|
Modifier, TuiColumn, TuiConstraint, TuiContainer, TuiElement, TuiLayoutContext,
|
|
TuiParentElement, TuiSize, TuiStyle, TuiText,
|
|
};
|
|
use warpui_core::elements::Fill;
|
|
use warpui_core::{AppContext, Entity, EntityIdMap, TuiView};
|
|
|
|
const INPUT_PREFIX: &str = "≫ ";
|
|
|
|
/// Renderable pieces of an agent block; this will grow as we add tool calls and other sub-elements.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
enum TuiAIBlockSection {
|
|
Input(String),
|
|
PlainText(String),
|
|
}
|
|
|
|
/// A thin TUI rich-content view adapter backed by one agent exchange.
|
|
///
|
|
/// The rendering logic is mostly section extraction, but the shared block list
|
|
/// stores rich content by view id, so this remains a registered view.
|
|
pub(super) struct TuiAIBlock {
|
|
conversation_id: AIConversationId,
|
|
exchange_id: AIAgentExchangeId,
|
|
model: Rc<dyn AIBlockModel<View = Self>>,
|
|
}
|
|
|
|
/// Extracts model state into renderable agent block sections.
|
|
impl TuiAIBlock {
|
|
/// Creates a simple exchange-backed agent block.
|
|
pub(super) fn new(
|
|
conversation_id: AIConversationId,
|
|
exchange_id: AIAgentExchangeId,
|
|
model: Rc<dyn AIBlockModel<View = Self>>,
|
|
) -> Self {
|
|
Self {
|
|
conversation_id,
|
|
exchange_id,
|
|
model,
|
|
}
|
|
}
|
|
|
|
/// Replaces the backing model when the same exchange is reassigned.
|
|
pub(super) fn replace_model(
|
|
&mut self,
|
|
conversation_id: AIConversationId,
|
|
model: Rc<dyn AIBlockModel<View = Self>>,
|
|
) {
|
|
self.conversation_id = conversation_id;
|
|
self.model = model;
|
|
}
|
|
|
|
/// Returns the conversation that currently owns this agent block.
|
|
pub(super) fn conversation_id(&self) -> AIConversationId {
|
|
self.conversation_id
|
|
}
|
|
|
|
/// Returns the exchange rendered by this agent block.
|
|
pub(super) fn exchange_id(&self) -> AIAgentExchangeId {
|
|
self.exchange_id
|
|
}
|
|
|
|
/// Returns this block's wrapped height at the given width.
|
|
pub(super) fn desired_height(&self, width: u16, app: &AppContext) -> usize {
|
|
let mut rendered_views = EntityIdMap::default();
|
|
let mut ctx = TuiLayoutContext {
|
|
rendered_views: &mut rendered_views,
|
|
};
|
|
let mut element = self.render_element(app);
|
|
usize::from(
|
|
element
|
|
.layout(
|
|
TuiConstraint::loose(TuiSize::new(width, u16::MAX)),
|
|
&mut ctx,
|
|
app,
|
|
)
|
|
.height,
|
|
)
|
|
}
|
|
|
|
/// Extracts this exchange's visible input/output into logical render sections.
|
|
fn sections(&self, app: &AppContext) -> Vec<TuiAIBlockSection> {
|
|
let mut sections = Vec::new();
|
|
let input = self
|
|
.model
|
|
.inputs_to_render(app)
|
|
.iter()
|
|
.filter_map(|input| input.display_query())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
if !input.is_empty() {
|
|
sections.push(TuiAIBlockSection::Input(input));
|
|
}
|
|
|
|
if let Some(output) = self.model.status(app).output_to_render() {
|
|
let output = output.get();
|
|
sections.extend(output.text_from_agent_output().flat_map(|text| {
|
|
text.sections.iter().filter_map(|section| match section {
|
|
AIAgentTextSection::PlainText { text } => (!text.text().is_empty())
|
|
.then(|| TuiAIBlockSection::PlainText(text.text().to_owned())),
|
|
// Add item variants here as the TUI learns to render richer sections.
|
|
AIAgentTextSection::Code { .. }
|
|
| AIAgentTextSection::Table { .. }
|
|
| AIAgentTextSection::Image { .. }
|
|
| AIAgentTextSection::MermaidDiagram { .. } => None,
|
|
})
|
|
}));
|
|
}
|
|
|
|
sections
|
|
}
|
|
|
|
/// Builds this block's generic TUI element tree.
|
|
fn render_element(&self, app: &AppContext) -> Box<dyn TuiElement> {
|
|
let sections = self.sections(app);
|
|
|
|
let mut column = TuiColumn::new();
|
|
for (index, section) in sections.iter().enumerate() {
|
|
// Output is many sections (one per text section), so top padding is
|
|
// applied only to the section right after the input, giving a single
|
|
// gap at the input→output boundary rather than before every line.
|
|
let follows_input = index
|
|
.checked_sub(1)
|
|
.is_some_and(|prev| matches!(sections[prev], TuiAIBlockSection::Input(_)));
|
|
column = column.with_child(section.render_element(u16::from(follows_input), app));
|
|
}
|
|
|
|
// No background of its own: the block shows the terminal's background,
|
|
// matching the Figma where only the input line is highlighted.
|
|
TuiContainer::new(column)
|
|
.with_padding_bottom(u16::from(!sections.is_empty()))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Converts one logical section into a renderable TUI element.
|
|
impl TuiAIBlockSection {
|
|
fn render_element(&self, top_padding: u16, app: &AppContext) -> Box<dyn TuiElement> {
|
|
let theme = Appearance::as_ref(app).theme();
|
|
match self {
|
|
Self::Input(text) => {
|
|
let text_color = Fill::from(theme.foreground()).into();
|
|
let accent = ThemeFill::from(theme.terminal_colors().normal.cyan);
|
|
let background = Fill::from(
|
|
theme
|
|
.background()
|
|
.blend(&accent.with_opacity(10))
|
|
.blend(&accent.with_opacity(10)),
|
|
)
|
|
.into();
|
|
// Only the first line carries the `≫` prompt marker; continuation
|
|
// lines are indented to the marker's width so they align beneath it.
|
|
let mut column = TuiColumn::new();
|
|
for (index, line) in text.split('\n').enumerate() {
|
|
let line_text = if index == 0 {
|
|
format!("{INPUT_PREFIX}{line}")
|
|
} else {
|
|
format!("{}{line}", " ".repeat(INPUT_PREFIX.chars().count()))
|
|
};
|
|
column = column.child(
|
|
TuiText::new(line_text).with_style(
|
|
TuiStyle::default()
|
|
.fg(text_color)
|
|
.bg(background)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
);
|
|
}
|
|
TuiContainer::new(column)
|
|
.with_background(background)
|
|
.with_padding_top(top_padding)
|
|
.finish()
|
|
}
|
|
Self::PlainText(text) => {
|
|
let text_color =
|
|
Fill::from(ThemeFill::from(theme.terminal_colors().normal.white)).into();
|
|
TuiContainer::new(
|
|
TuiText::new(text.clone()).with_style(TuiStyle::default().fg(text_color)),
|
|
)
|
|
.with_padding_top(top_padding)
|
|
.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Registers the view with the TUI runtime.
|
|
impl Entity for TuiAIBlock {
|
|
type Event = ();
|
|
}
|
|
|
|
/// Renders the model-backed block as a TUI element.
|
|
impl TuiView for TuiAIBlock {
|
|
fn ui_name() -> &'static str {
|
|
"TuiAIBlock"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn TuiElement> {
|
|
self.render_element(app)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "agent_block_tests.rs"]
|
|
mod tests;
|