Files
galaxy/crates/warp_tui/src/transcript_view_tests.rs
T

293 lines
10 KiB
Rust

use std::rc::Rc;
use std::sync::Arc;
use parking_lot::FairMutex;
use warp::tui_export::{
AIAgentExchangeId, AIAgentInput, AIBlockModel, AIBlockOutputStatus, AIConversationId,
AIRequestType, BlockHeightItem, BlocklistAIHistoryModel, LLMId, OutputStatusUpdateCallback,
RichContentItem, RichContentType, ServerOutputId, TerminalModel,
};
use warpui::event::ModifiersState;
use warpui::platform::WindowStyle;
use warpui::{AddWindowOptions, App, EntityId, EntityIdMap, TuiView};
use warpui_core::elements::tui::{
TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiEvent, TuiEventContext,
TuiLayoutContext, TuiRect, TuiSize,
};
use warpui_core::keymap::Keystroke;
use warpui_core::presenter::tui::TuiPresenter;
use warpui_core::{AppContext, ViewContext};
use super::TuiTranscriptView;
use crate::agent_block::TuiAIBlock;
#[test]
fn transcript_view_renders_terminal_blocks_from_canonical_order() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| BlocklistAIHistoryModel::default());
let mut terminal_model = TerminalModel::mock(None, None);
terminal_model.simulate_block("echo 1", "1\r\n");
let terminal_model = Arc::new(FairMutex::new(terminal_model));
let model_for_view = terminal_model.clone();
let (_, transcript) = app.update(|ctx| {
ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
|ctx| TuiTranscriptView::new(EntityId::new(), model_for_view, ctx),
)
});
let mut presenter = TuiPresenter::new();
let frame =
app.update(|ctx| presenter.present(ctx, &transcript, TuiRect::new(0, 0, 80, 20)));
let text = frame.buffer.to_lines().join("\n");
assert!(
text.contains("echo 1"),
"transcript should render command input:\n{text}"
);
assert!(
text.contains('1'),
"transcript should render command output:\n{text}"
);
});
}
struct EmptyAgentBlockModel;
impl AIBlockModel for EmptyAgentBlockModel {
type View = TuiAIBlock;
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
AIBlockOutputStatus::Pending
}
fn server_output_id(&self, _app: &AppContext) -> Option<ServerOutputId> {
None
}
fn model_id(&self, _app: &AppContext) -> Option<LLMId> {
None
}
fn base_model<'a>(&'a self, _app: &'a AppContext) -> Option<&'a LLMId> {
None
}
fn inputs_to_render<'a>(&'a self, _app: &'a AppContext) -> &'a [AIAgentInput] {
&[]
}
fn conversation_id(&self, _app: &AppContext) -> Option<AIConversationId> {
None
}
fn on_updated_output(
&self,
_callback: OutputStatusUpdateCallback<Self::View>,
_ctx: &mut ViewContext<Self::View>,
) {
}
fn request_type(&self, _app: &AppContext) -> AIRequestType {
AIRequestType::Active
}
}
#[test]
fn transcript_agent_block_lifecycle_updates_canonical_rich_content() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| BlocklistAIHistoryModel::default());
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let model_for_view = terminal_model.clone();
let (_, transcript) = app.update(|ctx| {
ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
|ctx| TuiTranscriptView::new(EntityId::new(), model_for_view, ctx),
)
});
let original_conversation_id = AIConversationId::new();
let exchange_id = AIAgentExchangeId::new();
transcript.update(&mut app, |view, ctx| {
let agent_block = ctx.add_tui_view(|_| {
TuiAIBlock::new(
original_conversation_id,
exchange_id,
Rc::new(EmptyAgentBlockModel),
)
});
let agent_block_id = agent_block.id();
view.agent_blocks
.borrow_mut()
.insert(agent_block_id, agent_block);
view.model.lock().block_list_mut().append_rich_content(
RichContentItem::new(Some(RichContentType::AIBlock), agent_block_id, None, false),
false,
);
ctx.notify();
});
let agent_block_id = transcript.read(&app, |view, _| {
assert_eq!(view.agent_blocks.borrow().len(), 1);
*view.agent_blocks.borrow().keys().next().unwrap()
});
assert!(
take_dirty_rich_content_items(&terminal_model).contains(&agent_block_id),
"appended TUI agent rich content should be dirty in the canonical block list"
);
assert_eq!(rich_content_count(&terminal_model), 1);
transcript.update(&mut app, |view, ctx| {
view.mark_exchange_dirty(exchange_id, ctx);
});
assert!(
take_dirty_rich_content_items(&terminal_model).contains(&agent_block_id),
"streaming updates should dirty canonical rich content"
);
transcript.read(&app, |view, app| {
let agent_blocks = view.agent_blocks.borrow();
let agent_block = agent_blocks
.values()
.next()
.expect("agent block should remain tracked");
assert_eq!(
agent_block.as_ref(app).conversation_id(),
original_conversation_id
);
});
assert_eq!(rich_content_count(&terminal_model), 1);
transcript.update(&mut app, |view, ctx| {
view.remove_conversation(original_conversation_id, ctx)
});
transcript.read(&app, |view, _| {
assert!(view.agent_blocks.borrow().is_empty());
});
assert_eq!(rich_content_count(&terminal_model), 0);
});
}
#[test]
fn transcript_view_scrolls_only_with_the_mouse_wheel() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| BlocklistAIHistoryModel::default());
let mut terminal_model = TerminalModel::mock(None, None);
for index in 0..8 {
let command = format!("echo {index}");
let output = format!("{index}\r\n");
terminal_model.simulate_block(command.as_str(), output.as_str());
}
let terminal_model = Arc::new(FairMutex::new(terminal_model));
let model_for_view = terminal_model.clone();
let (_, transcript) = app.update(|ctx| {
ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
|ctx| TuiTranscriptView::new(EntityId::new(), model_for_view, ctx),
)
});
let mut element = transcript.read(&app, |view, app| view.render(app));
let area = TuiRect::new(0, 0, 40, 4);
let bottom = render_element(&app, element.as_mut(), area);
assert!(transcript.read(&app, |view, _| view.viewport.is_at_end()));
let page_up = TuiEvent::KeyDown {
keystroke: Keystroke {
key: "pageup".to_owned(),
..Default::default()
},
chars: String::new(),
details: Default::default(),
is_composing: false,
};
assert!(!dispatch_event(&app, element.as_mut(), area, &page_up));
assert_eq!(render_element(&app, element.as_mut(), area), bottom);
assert!(dispatch_scroll(&app, element.as_mut(), area, 1));
let scrolled = render_element(&app, element.as_mut(), area);
assert_ne!(scrolled, bottom);
assert!(!transcript.read(&app, |view, _| view.viewport.is_at_end()));
for _ in 0..8 {
dispatch_scroll(&app, element.as_mut(), area, -1);
}
assert_eq!(render_element(&app, element.as_mut(), area), bottom);
assert!(transcript.read(&app, |view, _| view.viewport.is_at_end()));
});
}
/// Lays out and renders a retained TUI element.
fn render_element(app: &App, element: &mut dyn TuiElement, area: TuiRect) -> Vec<String> {
app.read(|app| {
let mut rendered_views = EntityIdMap::default();
let mut ctx = TuiLayoutContext {
rendered_views: &mut rendered_views,
};
element.layout(
TuiConstraint::tight(TuiSize::new(area.width, area.height)),
&mut ctx,
app,
);
let mut buffer = TuiBuffer::empty(area);
element.render(area, &mut buffer, &mut ctx);
buffer.to_lines()
})
}
/// Dispatches a vertical wheel movement to a retained TUI element.
fn dispatch_scroll(app: &App, element: &mut dyn TuiElement, area: TuiRect, delta_y: isize) -> bool {
dispatch_event(
app,
element,
area,
&TuiEvent::ScrollWheel {
position: (area.x, area.y).into(),
delta: (0, delta_y),
precise: false,
modifiers: ModifiersState::default(),
},
)
}
/// Dispatches an event to a retained TUI element.
fn dispatch_event(
app: &App,
element: &mut dyn TuiElement,
area: TuiRect,
event: &TuiEvent,
) -> bool {
app.read(|app| {
let mut rendered_views = EntityIdMap::default();
let mut layout_ctx = TuiLayoutContext {
rendered_views: &mut rendered_views,
};
let mut event_ctx = TuiEventContext::default();
event_ctx.set_origin_view(Some(EntityId::new()));
element.dispatch_event(event, area, &mut event_ctx, &mut layout_ctx, app)
})
}
fn rich_content_count(model: &Arc<FairMutex<TerminalModel>>) -> usize {
model
.lock()
.block_list()
.block_heights()
.cursor::<(), ()>()
.filter(|item| matches!(item, BlockHeightItem::RichContent(_)))
.count()
}
fn take_dirty_rich_content_items(
model: &Arc<FairMutex<TerminalModel>>,
) -> std::collections::HashSet<EntityId> {
model
.lock()
.block_list_mut()
.take_dirty_rich_content_items()
}