first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+216
View File
@@ -0,0 +1,216 @@
//! 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;
+233
View File
@@ -0,0 +1,233 @@
use std::rc::Rc;
use warp::tui_export::{
AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType,
AIAgentText, AIAgentTextSection, AIBlockModel, AIBlockOutputStatus, AIConversationId,
AIRequestType, Appearance, LLMId, MessageId, OutputStatusUpdateCallback, ServerOutputId,
Shared, UserQueryMode,
};
use warp_core::ui::color::blend::Blend;
use warp_core::ui::theme::Fill as ThemeFill;
use warpui::SingletonEntity;
use warpui_core::elements::tui::{Color, Modifier, TuiBufferExt, TuiRect};
use warpui_core::elements::Fill as CoreFill;
use warpui_core::presenter::tui::TuiPresenter;
use warpui_core::{App, AppContext, ViewContext};
use super::{TuiAIBlock, TuiAIBlockSection};
#[test]
fn simple_agent_block_reports_full_height_and_renders_content() {
App::test((), |app| async move {
app.add_singleton_model(|_| Appearance::mock());
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: vec![query_input("hello")],
status: complete_output(vec![AIAgentTextSection::PlainText {
text: "one\ntwo\nthree".to_owned().into(),
}]),
});
assert_eq!(block.desired_height(20, app_ctx), 6);
let mut presenter = TuiPresenter::new();
let frame = presenter.present_element(
block.render_element(app_ctx),
TuiRect::new(0, 0, 20, 6),
app_ctx,
);
assert_eq!(
frame
.buffer
.to_lines()
.into_iter()
.map(|line| line.trim_end().to_owned())
.collect::<Vec<_>>(),
vec!["≫ hello", "", "one", "two", "three", ""],
);
assert_eq!(frame.buffer[(0, 0)].fg, expected_prompt_text_color(app_ctx));
assert_eq!(frame.buffer[(0, 0)].bg, expected_input_background(app_ctx));
assert!(frame.buffer[(0, 0)].modifier.contains(Modifier::BOLD));
assert_eq!(frame.buffer[(2, 0)].fg, expected_prompt_text_color(app_ctx));
assert_eq!(frame.buffer[(19, 0)].bg, expected_input_background(app_ctx));
assert_eq!(frame.buffer[(0, 2)].fg, expected_output_text_color(app_ctx));
// The block paints no background of its own, so output rows show the
// terminal's own background.
assert_eq!(frame.buffer[(0, 2)].bg, Color::Reset);
assert_eq!(frame.buffer[(19, 2)].bg, Color::Reset);
});
});
}
#[test]
fn simple_agent_block_reflows_height_at_narrow_width() {
App::test((), |app| async move {
app.add_singleton_model(|_| Appearance::mock());
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: vec![query_input("hello world")],
status: complete_output(vec![AIAgentTextSection::PlainText {
text: "streamed output".to_owned().into(),
}]),
});
let wide = block.desired_height(40, app_ctx);
let narrow = block.desired_height(6, app_ctx);
assert!(narrow > wide, "narrow text should occupy more logical rows");
});
});
}
fn expected_prompt_text_color(app: &AppContext) -> Color {
let theme = Appearance::as_ref(app).theme();
CoreFill::from(theme.foreground()).into()
}
fn expected_input_background(app: &AppContext) -> Color {
let theme = Appearance::as_ref(app).theme();
let accent = ThemeFill::from(theme.terminal_colors().normal.cyan);
CoreFill::from(
theme
.background()
.blend(&accent.with_opacity(10))
.blend(&accent.with_opacity(10)),
)
.into()
}
fn expected_output_text_color(app: &AppContext) -> Color {
let theme = Appearance::as_ref(app).theme();
CoreFill::from(ThemeFill::from(theme.terminal_colors().normal.white)).into()
}
#[test]
fn agent_block_extracts_input_and_plain_text_from_model() {
App::test((), |app| async move {
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: vec![query_input("hello")],
status: complete_output(vec![
AIAgentTextSection::PlainText {
text: "one".to_owned().into(),
},
AIAgentTextSection::PlainText {
text: "two".to_owned().into(),
},
]),
});
assert_eq!(
block.sections(app_ctx),
vec![
TuiAIBlockSection::Input("hello".to_owned()),
TuiAIBlockSection::PlainText("one".to_owned()),
TuiAIBlockSection::PlainText("two".to_owned()),
]
);
});
});
}
#[test]
fn agent_block_omits_unsupported_sections_until_the_tui_can_render_them() {
App::test((), |app| async move {
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: Vec::new(),
status: complete_output(vec![
AIAgentTextSection::Code {
code: "println!(\"hi\");".to_owned(),
language: None,
source: None,
},
AIAgentTextSection::PlainText {
text: "visible".to_owned().into(),
},
]),
});
assert_eq!(
block.sections(app_ctx),
vec![TuiAIBlockSection::PlainText("visible".to_owned())]
);
});
});
}
struct FakeAgentBlockModel {
inputs: Vec<AIAgentInput>,
status: AIBlockOutputStatus,
}
/// Builds an agent block with fresh test identity.
fn test_agent_block(model: FakeAgentBlockModel) -> TuiAIBlock {
TuiAIBlock::new(
AIConversationId::new(),
AIAgentExchangeId::new(),
Rc::new(model),
)
}
impl AIBlockModel for FakeAgentBlockModel {
type View = TuiAIBlock;
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
self.status.clone()
}
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] {
&self.inputs
}
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
}
}
/// Builds a completed output status with one text message.
fn complete_output(sections: Vec<AIAgentTextSection>) -> AIBlockOutputStatus {
AIBlockOutputStatus::Complete {
output: Shared::new(AIAgentOutput {
messages: vec![AIAgentOutputMessage {
id: MessageId::new("message-1".to_owned()),
message: AIAgentOutputMessageType::Text(AIAgentText { sections }),
citations: Vec::new(),
}],
..Default::default()
}),
}
}
/// Builds one user-query input for model-backed extraction tests.
fn query_input(query: &str) -> AIAgentInput {
AIAgentInput::UserQuery {
query: query.to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::default(),
running_command: None,
intended_agent: None,
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Dev-channel `warp-tui` binary (internal nightly builds).
//!
//! Mirrors `app/src/bin/dev.rs`: loads the internal `dev` channel config and
//! layers the dev feature flags, then hands off to the shared TUI entry point.
use anyhow::Result;
use warp_core::channel::{Channel, ChannelState};
use warp_core::features;
fn main() -> Result<()> {
ChannelState::set(
ChannelState::new(Channel::Dev, warp_channel_config::load_config!("dev"))
.with_additional_features(features::DEBUG_FLAGS)
.with_additional_features(features::DOGFOOD_FLAGS)
.with_additional_features(features::PREVIEW_FLAGS),
);
warp_tui::run()
}
+23
View File
@@ -0,0 +1,23 @@
//! Local-channel `warp-tui` binary (internal dev builds).
//!
//! Mirrors `app/src/bin/local.rs`: loads the internal `local` channel config
//! (via the `warp-channel-config` generator) and layers the dev feature flags,
//! then hands off to the shared TUI entry point. Run it through
//! `./script/run-tui`, which installs the generator first; running it directly
//! without `warp-channel-config` on PATH will panic with install instructions.
use anyhow::Result;
use warp_core::channel::{Channel, ChannelState};
use warp_core::features;
fn main() -> Result<()> {
ChannelState::set(
ChannelState::new(Channel::Local, warp_channel_config::load_config!("local"))
.with_additional_features(features::DEBUG_FLAGS)
.with_additional_features(features::DOGFOOD_FLAGS)
.with_additional_features(features::PREVIEW_FLAGS)
.with_additional_features(features::LOCAL_FLAGS),
);
warp_tui::run()
}
+33
View File
@@ -0,0 +1,33 @@
//! OSS-channel `warp-tui` binary and `default-run` target.
//!
//! This is what bare `cargo run -p warp_tui` builds, so it hand-builds a
//! production config and needs no internal `warp-channel-config` generator
//! (mirrors `app/src/bin/oss.rs`). It is a console application (no GUI window,
//! no app bundle), so unlike the GUI binaries it sets no `windows_subsystem`
//! attribute and embeds no `Info.plist`.
use anyhow::Result;
use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig};
use warp_core::AppId;
fn main() -> Result<()> {
let mut state = ChannelState::new(
Channel::Oss,
ChannelConfig {
app_id: AppId::new("dev", "warp", "WarpTui"),
logfile_name: "warp-tui.log".into(),
server_config: WarpServerConfig::production(),
oz_config: OzConfig::production(),
telemetry_config: None,
crash_reporting_config: None,
autoupdate_config: None,
mcp_static_config: None,
},
);
if cfg!(debug_assertions) {
state = state.with_additional_features(warp_core::features::DEBUG_FLAGS);
}
ChannelState::set(state);
warp_tui::run()
}
+22
View File
@@ -0,0 +1,22 @@
//! Preview-channel `warp-tui` binary.
//!
//! Mirrors `app/src/bin/preview.rs`: loads the `preview` channel config and
//! enables the preview feature flags (plus forced login), then hands off to the
//! shared TUI entry point.
use anyhow::Result;
use warp_core::channel::{Channel, ChannelState};
use warp_core::features;
fn main() -> Result<()> {
ChannelState::set(
ChannelState::new(
Channel::Preview,
warp_channel_config::load_config!("preview"),
)
.with_additional_features(features::PREVIEW_FLAGS)
.with_additional_features(&[features::FeatureFlag::ForceLogin]),
);
warp_tui::run()
}
+16
View File
@@ -0,0 +1,16 @@
//! Stable-channel `warp-tui` binary.
//!
//! Mirrors `app/src/bin/stable.rs`: loads the `stable` channel config with no
//! additional feature flags, then hands off to the shared TUI entry point.
use anyhow::Result;
use warp_core::channel::{Channel, ChannelState};
fn main() -> Result<()> {
ChannelState::set(ChannelState::new(
Channel::Stable,
warp_channel_config::load_config!("stable"),
));
warp_tui::run()
}
@@ -0,0 +1,250 @@
use warp::tui_export::{
AIConversationAutoexecuteMode, AIConversationId, AgentViewEntryOrigin, BlocklistAIHistoryEvent,
BlocklistAIHistoryModel, ConversationSelection, ConversationSelectionEvent,
EnterAgentViewError, PendingQueryState,
};
use warpui::{AppContext, EntityId, ModelContext, SingletonEntity};
/// TUI-owned next-prompt conversation selection.
pub(super) struct TuiConversationSelection {
terminal_surface_id: EntityId,
pending_query_state: PendingQueryState,
}
impl TuiConversationSelection {
/// Creates TUI conversation selection for a terminal surface.
pub(super) fn new(
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) -> Self {
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|selection, _, event, ctx| selection.handle_history_event(event, ctx),
);
let pending_query_state =
if warp_core::execution_mode::AppExecutionMode::as_ref(ctx).is_sandboxed() {
PendingQueryState::New {
autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion,
}
} else {
PendingQueryState::default()
};
Self {
terminal_surface_id,
pending_query_state,
}
}
/// Returns the selected existing conversation ID.
fn selected_id(&self) -> Option<AIConversationId> {
match self.pending_query_state {
PendingQueryState::Existing { conversation_id } => Some(conversation_id),
PendingQueryState::New { .. } => None,
}
}
/// Updates pending state and emits only when the value changes.
fn set_pending_query_state(
&mut self,
state: PendingQueryState,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if self.pending_query_state != state {
self.pending_query_state = state;
ctx.emit(ConversationSelectionEvent::Changed);
}
}
/// Emits activation for a selected TUI conversation.
fn emit_activated(
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
ctx.emit(ConversationSelectionEvent::Activated {
is_fullscreen: true,
origin,
});
}
/// Emits deactivation for a previously selected TUI conversation.
fn emit_deactivated(
conversation_id: AIConversationId,
is_exit_before_new_entrance: bool,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
let final_exchange_count = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.map(|conversation| conversation.exchange_count())
.unwrap_or(0);
ctx.emit(ConversationSelectionEvent::Deactivated {
conversation_id,
final_exchange_count,
is_exit_before_new_entrance,
});
}
}
impl ConversationSelection for TuiConversationSelection {
fn selected_conversation_id(&self, _: &AppContext) -> Option<AIConversationId> {
self.selected_id()
}
fn is_conversation_active(&self, _: &AppContext) -> bool {
self.selected_id().is_some()
}
/// The TUI has no terminal/Agent View split, so every selected conversation is fullscreen.
fn is_conversation_fullscreen(&self, _: &AppContext) -> bool {
self.selected_id().is_some()
}
fn select_existing_conversation(
&mut self,
conversation_id: AIConversationId,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
let previous_conversation_id = self.selected_id();
if previous_conversation_id == Some(conversation_id) {
return;
}
if let Some(previous_conversation_id) = previous_conversation_id {
Self::emit_deactivated(previous_conversation_id, true, ctx);
}
self.set_pending_query_state(PendingQueryState::Existing { conversation_id }, ctx);
Self::emit_activated(origin, ctx);
}
fn select_new_conversation(
&mut self,
_: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
let previous_conversation_id = self.selected_id();
self.set_pending_query_state(PendingQueryState::default(), ctx);
if let Some(previous_conversation_id) = previous_conversation_id {
Self::emit_deactivated(previous_conversation_id, false, ctx);
}
}
fn try_start_new_conversation(
&mut self,
origin: AgentViewEntryOrigin,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) -> Result<AIConversationId, EnterAgentViewError> {
if let Some(previous_conversation_id) = self.selected_id() {
Self::emit_deactivated(previous_conversation_id, true, ctx);
}
let is_autoexecute_override = matches!(
self.pending_query_state,
PendingQueryState::New {
autoexecute_override: AIConversationAutoexecuteMode::RunToCompletion,
}
);
let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(
self.terminal_surface_id,
is_autoexecute_override,
false,
false,
ctx,
)
});
self.set_pending_query_state(PendingQueryState::Existing { conversation_id }, ctx);
Self::emit_activated(origin, ctx);
Ok(conversation_id)
}
fn pending_query_autoexecute_override(
&self,
app: &AppContext,
) -> AIConversationAutoexecuteMode {
match &self.pending_query_state {
PendingQueryState::New {
autoexecute_override,
} => *autoexecute_override,
PendingQueryState::Existing { conversation_id } => BlocklistAIHistoryModel::as_ref(app)
.conversation(conversation_id)
.map(|conversation| conversation.autoexecute_override())
.unwrap_or_default(),
}
}
fn toggle_pending_query_autoexecute(
&mut self,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
match self.pending_query_state.clone() {
PendingQueryState::New {
autoexecute_override,
} => {
let autoexecute_override =
if autoexecute_override == AIConversationAutoexecuteMode::RespectUserSettings {
AIConversationAutoexecuteMode::RunToCompletion
} else {
AIConversationAutoexecuteMode::RespectUserSettings
};
self.set_pending_query_state(
PendingQueryState::New {
autoexecute_override,
},
ctx,
);
}
PendingQueryState::Existing { conversation_id } => {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
&conversation_id,
self.terminal_surface_id,
ctx,
);
});
}
}
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
) {
if event
.terminal_surface_id()
.is_some_and(|id| id != self.terminal_surface_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
self.select_new_conversation(AgentViewEntryOrigin::Cli, ctx);
}
BlocklistAIHistoryEvent::SplitConversation {
old_conversation_id,
new_conversation_id,
..
} if self.selected_id() == Some(*old_conversation_id) => {
self.select_existing_conversation(
*new_conversation_id,
AgentViewEntryOrigin::AgentRequestedNewConversation,
ctx,
);
}
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces {
conversation_id,
..
} if self.selected_id() == Some(*conversation_id) => {
self.select_new_conversation(AgentViewEntryOrigin::Cli, ctx);
}
_ => {}
}
}
}
#[cfg(test)]
#[path = "conversation_selection_tests.rs"]
mod tests;
@@ -0,0 +1,150 @@
use warp::tui_export::{
AIConversationId, AgentViewEntryOrigin, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
ConversationSelection, ConversationSelectionHandle,
};
use warp_core::execution_mode::{AppExecutionMode, ExecutionMode};
use warpui::{App, EntityId, ModelHandle};
use super::TuiConversationSelection;
fn build_tui_selection(
app: &mut App,
) -> (
ModelHandle<BlocklistAIHistoryModel>,
ConversationSelectionHandle,
EntityId,
) {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, false, ctx));
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::default());
let terminal_surface_id = EntityId::new();
let selection = app.add_model(|ctx| {
Box::new(TuiConversationSelection::new(terminal_surface_id, ctx))
as Box<dyn ConversationSelection>
});
(history, selection, terminal_surface_id)
}
#[test]
fn tui_selection_owns_next_prompt_selection() {
App::test((), |mut app| async move {
let (_, selection, _) = build_tui_selection(&mut app);
let conversation_id = AIConversationId::new();
selection.update(&mut app, |selection, ctx| {
selection.select_existing_conversation(conversation_id, AgentViewEntryOrigin::Cli, ctx);
});
selection.read(&app, |selection, ctx| {
assert_eq!(
selection.selected_conversation_id(ctx),
Some(conversation_id)
);
assert!(selection.is_conversation_active(ctx));
assert!(selection.is_conversation_fullscreen(ctx));
});
selection.update(&mut app, |selection, ctx| {
selection.select_new_conversation(AgentViewEntryOrigin::Cli, ctx);
});
selection.read(&app, |selection, ctx| {
assert_eq!(selection.selected_conversation_id(ctx), None);
assert!(!selection.is_conversation_active(ctx));
assert!(!selection.is_conversation_fullscreen(ctx));
});
});
}
#[test]
fn tui_selection_creates_and_selects_terminal_surface_scoped_conversation() {
App::test((), |mut app| async move {
let (history, selection, terminal_surface_id) = build_tui_selection(&mut app);
let conversation_id = selection
.update(&mut app, |selection, ctx| {
selection.try_start_new_conversation(AgentViewEntryOrigin::Cli, ctx)
})
.expect("TUI conversation creation should succeed");
selection.read(&app, |selection, ctx| {
assert_eq!(
selection.selected_conversation_id(ctx),
Some(conversation_id)
);
});
history.read(&app, |history, _| {
assert_eq!(
history
.all_live_conversations_for_terminal_surface(terminal_surface_id)
.map(|conversation| conversation.id())
.collect::<Vec<_>>(),
vec![conversation_id]
);
});
});
}
#[test]
fn tui_selection_reconciles_split_and_removed_selection() {
App::test((), |mut app| async move {
let (history, selection, terminal_surface_id) = build_tui_selection(&mut app);
let old_conversation_id = AIConversationId::new();
let new_conversation_id = AIConversationId::new();
selection.update(&mut app, |selection, ctx| {
selection.select_existing_conversation(
old_conversation_id,
AgentViewEntryOrigin::Cli,
ctx,
);
});
history.update(&mut app, |_, ctx| {
ctx.emit(BlocklistAIHistoryEvent::SplitConversation {
terminal_surface_id,
old_conversation_id,
new_conversation_id,
});
});
selection.read(&app, |selection, ctx| {
assert_eq!(
selection.selected_conversation_id(ctx),
Some(new_conversation_id)
);
});
history.update(&mut app, |_, ctx| {
ctx.emit(BlocklistAIHistoryEvent::RemoveConversation {
terminal_surface_id,
conversation_id: new_conversation_id,
run_id: None,
});
});
selection.read(&app, |selection, ctx| {
assert_eq!(selection.selected_conversation_id(ctx), None);
});
});
}
#[test]
fn tui_new_conversation_preserves_pending_autoexecute_override() {
App::test((), |mut app| async move {
app.add_singleton_model(|ctx| AppExecutionMode::new(ExecutionMode::App, true, ctx));
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::default());
let terminal_surface_id = EntityId::new();
let selection = app.add_model(|ctx| {
Box::new(TuiConversationSelection::new(terminal_surface_id, ctx))
as Box<dyn ConversationSelection>
});
let conversation_id = selection
.update(&mut app, |selection, ctx| {
selection.try_start_new_conversation(AgentViewEntryOrigin::Cli, ctx)
})
.expect("TUI conversation creation should succeed");
history.read(&app, |history, _| {
assert!(history
.conversation(&conversation_id)
.expect("conversation should exist")
.autoexecute_any_action());
});
});
}
+44
View File
@@ -0,0 +1,44 @@
/// A single-entry kill buffer for the TUI input view.
///
/// Stores the last text killed by `Ctrl+K`, `Ctrl+U`, `Ctrl+W`, or `Alt+D`.
/// `Ctrl+Y` yanks (pastes) the stored text back into the input.
///
/// This is intentionally simple — a kill ring (multi-entry yank cycle) is
/// listed as a follow-up in `specs/tui-input-view/TECH.md`.
#[derive(Debug, Default)]
pub struct KillBuffer {
content: String,
}
impl KillBuffer {
/// Store `text` as the killed content, replacing any previous entry.
pub fn kill(&mut self, text: impl Into<String>) {
self.content = text.into();
}
/// Append `text` to the current kill buffer content.
/// Used when multiple consecutive kills are combined (e.g. `Ctrl+K` at
/// the end of one line followed immediately by another `Ctrl+K`).
pub fn kill_append(&mut self, text: impl Into<String>) {
self.content.push_str(&text.into());
}
/// Return the killed text for yanking, if any.
pub fn yank(&self) -> Option<&str> {
if self.content.is_empty() {
None
} else {
Some(&self.content)
}
}
/// Return whether the kill buffer is empty.
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
/// Clear the kill buffer.
pub fn clear(&mut self) {
self.content.clear();
}
}
+14
View File
@@ -0,0 +1,14 @@
//! TUI input view.
//!
//! The key types are:
//! - [`view::TuiInputView`] — ratatui-rendered view implementing [`TuiView`], backed
//! by a [`warp::editor::CodeEditorModel`] in char-cell mode
//! - [`view::TuiInputViewEvent`] — events emitted by the view (e.g. `Submitted`)
//!
//! TUI-specific session state (kill buffer, scroll offset, terminal width) lives on
//! the view, not on a separate model. See `specs/tui-input-view/TECH.md` for details.
pub mod kill_buffer;
pub mod view;
pub use view::{TuiInputView, TuiInputViewEvent};
File diff suppressed because it is too large Load Diff
+617
View File
@@ -0,0 +1,617 @@
//! Regression tests for [`TuiInputView`] cursor/coordinate + kill logic.
//!
//! These drive a real [`CodeEditorModel`] (TUI char-cell mode) behind a real
//! [`TuiInputView`] so they exercise the exact render/layout/cursor path the
//! presenter uses, not a reimplementation of it.
use warp::appearance::Appearance;
use warp::editor::CodeEditorModel;
use warp_core::semantic_selection::SemanticSelection;
use warp_editor::model::CoreEditorModel;
use warpui::EntityIdMap;
use warpui_core::elements::tui::{
TuiConstraint, TuiElement, TuiEvent, TuiLayoutContext, TuiPoint, TuiRect, TuiSize,
};
use warpui_core::event::ModifiersState;
use warpui_core::platform::WindowStyle;
use warpui_core::{AddWindowOptions, App, AppContext, TuiView, TypedActionView, ViewHandle};
use super::{TuiInputAction, TuiInputElement, TuiInputView};
const W: u16 = 80;
fn build_view(ctx: &mut AppContext) -> ViewHandle<TuiInputView> {
// `CodeEditorModel::new_tui` reads syntax colors from the `Appearance`
// singleton, so register a mock one before constructing the editor.
ctx.add_singleton_model(|_| Appearance::mock());
// Double-click word selection reads the `SemanticSelection` singleton for
// its word-boundary policy, so register a mock one too.
ctx.add_singleton_model(|_| SemanticSelection::mock(true, ""));
let (_window_id, view) = ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
|ctx| {
let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx));
TuiInputView::new(model, ctx)
},
);
view
}
fn dispatch(view: &ViewHandle<TuiInputView>, ctx: &mut AppContext, actions: &[TuiInputAction]) {
view.update(ctx, |v, vctx| {
for action in actions {
v.handle_action(action, vctx);
}
});
}
fn type_str(view: &ViewHandle<TuiInputView>, ctx: &mut AppContext, s: &str) {
let actions: Vec<TuiInputAction> = s.chars().map(TuiInputAction::InsertChar).collect();
dispatch(view, ctx, &actions);
}
/// Render the view, lay it out at width `W`, and return `(cursor, height)`.
fn cursor_and_height(
view: &ViewHandle<TuiInputView>,
ctx: &AppContext,
) -> (Option<(u16, u16)>, u16) {
let mut element = view.as_ref(ctx).render(ctx);
let mut rendered_views = EntityIdMap::default();
let mut lctx = TuiLayoutContext {
rendered_views: &mut rendered_views,
};
let size = element.layout(TuiConstraint::loose(TuiSize::new(W, 20)), &mut lctx, ctx);
let cursor = element.cursor_position(TuiRect::new(0, 0, size.width, size.height), &mut lctx);
(cursor, size.height)
}
fn text(view: &ViewHandle<TuiInputView>, ctx: &AppContext) -> String {
let v = view.as_ref(ctx);
let inner = v.model().as_ref(ctx);
let buffer = inner.content().as_ref(ctx);
if buffer.is_empty() {
String::new()
} else {
buffer.text().into_string()
}
}
/// The currently selected substring, or `None` when there is no selection.
fn selected_text(view: &ViewHandle<TuiInputView>, ctx: &AppContext) -> Option<String> {
let range = view.as_ref(ctx).selection_range(ctx)?;
// `selection_range` is a 1-based gap range; convert to 0-based plain-text indices.
let start = range.start.as_usize().saturating_sub(1);
let end = range.end.as_usize().saturating_sub(1);
let full = text(view, ctx);
Some(full.chars().skip(start).take(end - start).collect())
}
#[test]
fn cursor_at_origin_when_empty() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(cursor, Some((0, 0)));
assert_eq!(height, 1);
});
});
}
/// Regression: navigating a freshly-built (empty, never-edited) view must not
/// panic. The char-cell `line_starts` is seeded with `[0]` at construction, so
/// the soft-wrap helpers reached via `move_to_line_start` etc. index it safely
/// before the first edit ever runs `CharCellState::update_text`.
#[test]
fn navigation_on_empty_buffer_does_not_panic() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
dispatch(
&view,
ctx,
&[
TuiInputAction::MoveToLineStart,
TuiInputAction::MoveToLineEnd,
TuiInputAction::MoveLeft,
TuiInputAction::MoveRight,
TuiInputAction::MoveUp,
TuiInputAction::MoveDown,
],
);
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(cursor, Some((0, 0)));
assert_eq!(height, 1);
});
});
}
#[test]
fn cursor_tracks_end_of_single_line() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "ab");
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(cursor, Some((2, 0)));
assert_eq!(height, 1);
});
});
}
/// Bug 1: after a hard newline the cursor must render at the start of the new
/// (empty) row. Previously the empty trailing row laid out as 0 height, so the
/// column was only 1 row tall and the cursor (row 1) was clipped away.
#[test]
fn cursor_renders_at_start_of_new_line() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "ab");
dispatch(&view, ctx, &[TuiInputAction::InsertNewline]);
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(cursor, Some((0, 1)), "cursor should be at row 1, col 0");
assert!(height >= 2, "two visual rows expected, got height {height}");
});
});
}
/// Bug 2: an empty interior line must occupy its own row so following lines —
/// and the cursor — land on the correct visual row.
#[test]
fn interior_empty_line_does_not_collapse() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
// "a\n\nb"
type_str(&view, ctx, "a");
dispatch(
&view,
ctx,
&[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline],
);
type_str(&view, ctx, "b");
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(height, 3, "three visual rows expected");
assert_eq!(cursor, Some((1, 2)), "cursor should be on the 3rd row");
});
});
}
/// Bug 2 (navigation): moving up from the last line lands the cursor on the
/// correct (rendered) row, not a collapsed one.
#[test]
fn move_up_through_empty_line_positions_cursor() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "a");
dispatch(
&view,
ctx,
&[TuiInputAction::InsertNewline, TuiInputAction::InsertNewline],
);
type_str(&view, ctx, "b");
// Cursor on row 2 ("b"); move up to the empty row 1.
dispatch(&view, ctx, &[TuiInputAction::MoveUp]);
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(height, 3);
assert_eq!(
cursor,
Some((0, 1)),
"cursor should be on the empty 2nd row"
);
});
});
}
/// Kill bug: `Ctrl+K` from mid-line must delete from the cursor to the end of
/// the visual line (and nothing before it).
#[test]
fn kill_to_line_end_from_midline() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "abcd");
// Move cursor to just after 'b'.
dispatch(
&view,
ctx,
&[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft],
);
dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]);
assert_eq!(text(&view, ctx), "ab");
});
});
}
/// Kill bug: `Ctrl+K` at the end of a line is a no-op (nothing after cursor).
#[test]
fn kill_to_line_end_at_eol_is_noop() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "abcd");
dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]);
assert_eq!(text(&view, ctx), "abcd");
});
});
}
/// Kill bug: `Ctrl+U` from mid-line must delete from the start of the visual
/// line up to the cursor (and nothing after it).
#[test]
fn kill_to_line_start_from_midline() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "abcd");
dispatch(
&view,
ctx,
&[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft],
);
dispatch(&view, ctx, &[TuiInputAction::KillToLineStart]);
assert_eq!(text(&view, ctx), "cd");
});
});
}
/// Kill + yank round-trips the killed text at the cursor.
#[test]
fn kill_then_yank_round_trips() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "abcd");
dispatch(
&view,
ctx,
&[TuiInputAction::MoveLeft, TuiInputAction::MoveLeft],
);
dispatch(&view, ctx, &[TuiInputAction::KillToLineEnd]); // kills "cd" -> "ab"
dispatch(&view, ctx, &[TuiInputAction::Yank]); // yanks "cd" -> "abcd"
assert_eq!(text(&view, ctx), "abcd");
});
});
}
/// Bug 3: word-wise selection (Ctrl+Shift+←) extends the selection one word back.
#[test]
fn select_word_left_selects_previous_word() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
dispatch(&view, ctx, &[TuiInputAction::SelectWordLeft]);
assert_eq!(selected_text(&view, ctx).as_deref(), Some("world"));
});
});
}
/// Bug 3: word-wise selection (Ctrl+Shift+→) extends the selection one word forward.
#[test]
fn select_word_right_selects_next_word() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]);
dispatch(&view, ctx, &[TuiInputAction::SelectWordRight]);
assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello"));
});
});
}
/// Line-boundary navigation (Home/End) lands on the right column of a multi-line buffer.
#[test]
fn move_to_line_start_and_end_multiline() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "abc");
dispatch(&view, ctx, &[TuiInputAction::InsertNewline]);
type_str(&view, ctx, "def");
// Cursor is at end of "def" (row 1, col 3).
dispatch(&view, ctx, &[TuiInputAction::MoveToLineStart]);
assert_eq!(cursor_and_height(&view, ctx).0, Some((0, 1)));
dispatch(&view, ctx, &[TuiInputAction::MoveToLineEnd]);
assert_eq!(cursor_and_height(&view, ctx).0, Some((3, 1)));
});
});
}
/// Wide (double-width) CJK characters advance the cursor by two display columns
/// each, so the rendered cursor column reflects display width, not char count.
#[test]
fn cursor_accounts_for_wide_chars() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "你好");
let (cursor, height) = cursor_and_height(&view, ctx);
assert_eq!(
cursor,
Some((4, 0)),
"two double-width chars → cursor col 4"
);
assert_eq!(height, 1);
assert_eq!(text(&view, ctx), "你好");
});
});
}
/// A combining mark is zero-width: it shares its base character's cell, so
/// "a\u{0301}b" occupies two display columns and the cursor ends at column 2.
#[test]
fn cursor_accounts_for_zero_width_chars() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "a\u{0301}b");
let (cursor, _height) = cursor_and_height(&view, ctx);
assert_eq!(cursor, Some((2, 0)), "a + combining + b → 2 display cols");
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Mouse selection
// ─────────────────────────────────────────────────────────────────────────────
fn left_down(x: u16, y: u16, click_count: u32, shift: bool) -> TuiEvent {
TuiEvent::LeftMouseDown {
position: TuiPoint::new(x, y),
modifiers: ModifiersState {
shift,
..Default::default()
},
click_count,
is_first_mouse: false,
}
}
fn left_drag(x: u16, y: u16) -> TuiEvent {
TuiEvent::LeftMouseDragged {
position: TuiPoint::new(x, y),
modifiers: ModifiersState::default(),
}
}
fn left_up(x: u16, y: u16) -> TuiEvent {
TuiEvent::LeftMouseUp {
position: TuiPoint::new(x, y),
modifiers: ModifiersState::default(),
}
}
/// A mouse-wheel event at `(x, y)`. `delta_rows` follows crossterm's convention
/// (+1 = wheel up / toward the top, -1 = wheel down).
fn scroll_wheel(x: u16, y: u16, delta_rows: isize) -> TuiEvent {
TuiEvent::ScrollWheel {
position: TuiPoint::new(x, y),
delta: (0, delta_rows),
precise: false,
modifiers: ModifiersState::default(),
}
}
/// Types `n` short logical lines ("0".."n-1") into the input.
fn type_lines(view: &ViewHandle<TuiInputView>, ctx: &mut AppContext, n: usize) {
for i in 0..n {
if i > 0 {
dispatch(view, ctx, &[TuiInputAction::InsertNewline]);
}
type_str(view, ctx, &i.to_string());
}
}
/// Renders + lays out the view's element at width `W` (height capped by the
/// view), returning the concrete element and the area it occupies.
fn laid_out_element(
view: &ViewHandle<TuiInputView>,
ctx: &AppContext,
) -> (TuiInputElement, TuiRect) {
let mut element = view.as_ref(ctx).render_element(ctx);
let mut rendered_views = EntityIdMap::default();
let mut lctx = TuiLayoutContext {
rendered_views: &mut rendered_views,
};
let size = element.layout(TuiConstraint::loose(TuiSize::new(W, 20)), &mut lctx, ctx);
(element, TuiRect::new(0, 0, size.width, size.height))
}
/// Drives the full mouse path for `event`: lay out the element, map the event to
/// its [`TuiInputAction`], and apply that action to the view. Returns whether an
/// action fired (i.e. the event was not ignored).
fn mouse(view: &ViewHandle<TuiInputView>, ctx: &mut AppContext, event: &TuiEvent) -> bool {
let action = {
let (element, area) = laid_out_element(view, ctx);
element.mouse_action(event, area, ctx)
};
match action {
Some(action) => {
dispatch(view, ctx, &[action]);
true
}
None => false,
}
}
#[test]
fn single_click_places_cursor() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
assert!(mouse(&view, ctx, &left_down(3, 0, 1, false)));
assert!(mouse(&view, ctx, &left_up(3, 0)));
assert_eq!(cursor_and_height(&view, ctx).0, Some((3, 0)));
assert_eq!(selected_text(&view, ctx), None);
});
});
}
#[test]
fn click_outside_area_is_ignored() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hi");
// The single-line input is one row tall; row 5 is outside it.
assert!(!mouse(&view, ctx, &left_down(0, 5, 1, false)));
assert_eq!(selected_text(&view, ctx), None);
});
});
}
#[test]
fn drag_selects_range() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
mouse(&view, ctx, &left_down(0, 0, 1, false));
mouse(&view, ctx, &left_drag(5, 0));
assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello"));
mouse(&view, ctx, &left_up(5, 0));
assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello"));
});
});
}
#[test]
fn shift_click_extends_selection() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
// Place the cursor at the start, then shift-click after "hello".
mouse(&view, ctx, &left_down(0, 0, 1, false));
mouse(&view, ctx, &left_up(0, 0));
mouse(&view, ctx, &left_down(5, 0, 1, true));
assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello"));
});
});
}
#[test]
fn double_click_selects_word() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
assert!(mouse(&view, ctx, &left_down(2, 0, 2, false)));
assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello"));
});
});
}
#[test]
fn triple_click_selects_line() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_str(&view, ctx, "hello world");
assert!(mouse(&view, ctx, &left_down(2, 0, 3, false)));
assert_eq!(selected_text(&view, ctx).as_deref(), Some("hello world"));
});
});
}
#[test]
fn drag_past_last_visible_row_autoscrolls() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
// 10 logical lines, exceeding the 6-row viewport.
for i in 0..10 {
if i > 0 {
dispatch(&view, ctx, &[TuiInputAction::InsertNewline]);
}
type_str(&view, ctx, &i.to_string());
}
// Scroll back to the top.
for _ in 0..9 {
dispatch(&view, ctx, &[TuiInputAction::MoveUp]);
}
assert_eq!(view.as_ref(ctx).scroll_offset, 0);
// Begin a selection at the top, then drag well below the viewport.
mouse(&view, ctx, &left_down(0, 0, 1, false));
mouse(&view, ctx, &left_drag(0, 50));
// The head followed the drag to the last row, scrolling the viewport.
assert!(
view.as_ref(ctx).scroll_offset > 0,
"drag past the last visible row should auto-scroll"
);
assert!(selected_text(&view, ctx).is_some());
});
});
}
#[test]
fn wheel_scrolls_viewport_without_moving_cursor() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_lines(&view, ctx, 10); // 10 rows > 6-row viewport
// Typing leaves the cursor at the end, scrolled to the bottom.
assert_eq!(view.as_ref(ctx).scroll_offset, 4);
let cursor_before = view.as_ref(ctx).cursor_offset(ctx);
// Wheel up (delta +1) scrolls toward the top by WHEEL_STEP (2) rows.
assert!(mouse(&view, ctx, &scroll_wheel(0, 0, 1)));
assert_eq!(view.as_ref(ctx).scroll_offset, 2);
// Further wheel-ups clamp at the top.
mouse(&view, ctx, &scroll_wheel(0, 0, 1));
assert_eq!(view.as_ref(ctx).scroll_offset, 0);
mouse(&view, ctx, &scroll_wheel(0, 0, 1));
assert_eq!(view.as_ref(ctx).scroll_offset, 0);
// Scrolling never moved the cursor.
assert_eq!(view.as_ref(ctx).cursor_offset(ctx), cursor_before);
});
});
}
#[test]
fn wheel_scroll_down_clamps_at_bottom() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_lines(&view, ctx, 10);
// Scroll to the top first.
mouse(&view, ctx, &scroll_wheel(0, 0, 1));
mouse(&view, ctx, &scroll_wheel(0, 0, 1));
assert_eq!(view.as_ref(ctx).scroll_offset, 0);
// Wheel down (delta -1) scrolls toward the bottom, clamped at
// max_scroll = 10 rows - 6 visible = 4.
mouse(&view, ctx, &scroll_wheel(0, 0, -1));
assert_eq!(view.as_ref(ctx).scroll_offset, 2);
mouse(&view, ctx, &scroll_wheel(0, 0, -1));
assert_eq!(view.as_ref(ctx).scroll_offset, 4);
mouse(&view, ctx, &scroll_wheel(0, 0, -1));
assert_eq!(view.as_ref(ctx).scroll_offset, 4);
});
});
}
#[test]
fn wheel_outside_area_is_ignored() {
App::test((), |mut app| async move {
app.update(|ctx| {
let view = build_view(ctx);
type_lines(&view, ctx, 10);
let before = view.as_ref(ctx).scroll_offset;
// Row 50 is well outside the 6-row viewport.
assert!(!mouse(&view, ctx, &scroll_wheel(0, 50, 1)));
assert_eq!(view.as_ref(ctx).scroll_offset, before);
});
});
}
+23
View File
@@ -0,0 +1,23 @@
//! `warp_tui` — the headless TUI front-end for Warp.
//!
//! This crate contains:
//! - [`input`] — the editor-backed TUI input view (`TuiEditorModel` + `TuiInputView`).
//! - [`root_view`] — [`RootTuiView`], the login-gated transcript root view.
//! - [`session`] — [`run`], the binary entry point that boots the headless app
//! and starts the transcript-capable TUI draw + input driver.
//! - Binary entry points under `src/bin/`.
mod agent_block;
pub mod input;
pub mod root_view;
pub mod session;
mod ui;
mod conversation_selection;
mod terminal_block;
mod terminal_session_view;
mod transcript_view;
mod tui_block_list_viewport_source;
pub use root_view::RootTuiView;
pub use session::run;
+98
View File
@@ -0,0 +1,98 @@
//! [`RootTuiView`]: the login-gated root view of the `warp-tui` front-end.
use warp::tui_export::TerminalSurfaceInit;
use warp::{TuiLoginModel, TuiLoginPhase};
use warpui_core::elements::tui::{TuiChildView, TuiElement};
use warpui_core::{
keymap, AppContext, Entity, EntityId, SingletonEntity, TuiView, TypedActionView, ViewContext,
ViewHandle,
};
use crate::terminal_session_view::TuiTerminalSessionView;
use crate::ui::{login_failed, login_placeholder, terminal_starting};
/// Whether the authenticated terminal session has been created yet. Mirrors the
/// GUI root view's `AuthOnboardingState` split between the pre-session login gate
/// and the live terminal session.
enum RootTuiState {
/// Login gate: no terminal session exists yet. The placeholder shown is
/// chosen from the current [`TuiLoginPhase`].
Auth,
/// The authenticated terminal session.
Terminal(ViewHandle<TuiTerminalSessionView>),
}
/// The app-level TUI shell. It gates the authenticated terminal session on login state.
pub struct RootTuiView {
state: RootTuiState,
}
impl RootTuiView {
pub(crate) fn new() -> Self {
Self {
state: RootTuiState::Auth,
}
}
/// Creates the terminal child view once login has completed, or returns the
/// existing one if it was already created. Callers notify the root so it
/// re-renders from the login placeholder to the terminal session.
pub(crate) fn create_terminal_session(
&mut self,
surface_init: TerminalSurfaceInit,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<TuiTerminalSessionView> {
if let RootTuiState::Terminal(terminal_session) = &self.state {
return terminal_session.clone();
}
let terminal_session =
ctx.add_typed_action_tui_view(|ctx| TuiTerminalSessionView::new(surface_init, ctx));
self.state = RootTuiState::Terminal(terminal_session.clone());
terminal_session
}
}
impl Entity for RootTuiView {
type Event = ();
}
impl TuiView for RootTuiView {
fn ui_name() -> &'static str {
"RootTuiView"
}
fn child_view_ids(&self, _ctx: &AppContext) -> Vec<EntityId> {
// The TUI runtime uses this for child focus and event routing; only the
// live terminal session participates.
match &self.state {
RootTuiState::Terminal(terminal_session) => vec![terminal_session.id()],
RootTuiState::Auth => Vec::new(),
}
}
fn render(&self, ctx: &AppContext) -> Box<dyn TuiElement> {
match &self.state {
RootTuiState::Terminal(terminal_session) => {
TuiChildView::new(terminal_session).finish()
}
RootTuiState::Auth => match TuiLoginModel::as_ref(ctx).phase() {
TuiLoginPhase::LoggedIn => terminal_starting(),
TuiLoginPhase::AwaitingLogin {
verification_uri,
user_code,
} => login_placeholder(verification_uri.as_deref(), user_code.as_deref()),
TuiLoginPhase::Failed { message } => login_failed(message.as_str()),
},
}
}
fn keymap_context(&self, _ctx: &AppContext) -> keymap::Context {
// Propagate focus context into the input view so keystrokes reach it.
let mut context = keymap::Context::default();
context.set.insert("RootTuiView");
context
}
}
impl TypedActionView for RootTuiView {
type Action = ();
}
+142
View File
@@ -0,0 +1,142 @@
//! The headless `warp-tui` front-end's session bootstrap.
//!
//! [`run`] boots the real headless Warp app via [`warp::run_tui`]. Once shared
//! initialization is done, the mount built here starts the TUI driver and
//! defers creating the transcript-capable terminal session until login.
use std::collections::HashMap;
use std::ffi::OsString;
use anyhow::Result;
use pathfinder_geometry::vector::Vector2F;
use warp::tui_export::{
dark_theme, Appearance, BannerState, IsSharedSessionCreator, LocalTtyTerminalManager,
TerminalManagerTrait, TerminalSurfaceResult,
};
use warp::{TuiLoginModel, TuiLoginPhase};
use warpui::SingletonEntity;
use warpui_core::platform::{TerminationMode, WindowStyle};
use warpui_core::runtime::{spawn_tui_driver, TuiDriverHandle};
use warpui_core::{AddWindowOptions, AppContext, Entity, ModelHandle, ViewHandle};
use crate::root_view::RootTuiView;
use crate::terminal_session_view::TuiTerminalSessionView;
/// Holds the live TUI driver and, after login, the terminal manager.
struct TuiSession {
#[expect(dead_code, reason = "keeps the TUI driver alive for the TUI session")]
driver: TuiDriverHandle,
manager: Option<ModelHandle<Box<dyn TerminalManagerTrait>>>,
}
impl Entity for TuiSession {
type Event = ();
}
impl SingletonEntity for TuiSession {}
/// Boots the headless Warp app and mounts the transcript-capable TUI session.
pub fn run() -> Result<()> {
// If this process was re-exec'd as a Warp worker (e.g. the terminal
// server), dispatch that instead of starting another TUI — otherwise the
// worker re-exec would recursively launch TUIs.
if let Some(result) = warp::run_tui_worker_if_requested() {
return result;
}
warp::run_tui(Box::new(init))
}
/// Creates the login-gated TUI root and starts the headless draw + input driver.
fn init(ctx: &mut AppContext) {
// The current TUI transcript design is dark-mode-only. Keep this scoped to
// the TUI process by overriding the already-initialized Appearance theme at
// mount time, without changing normal GUI theme selection or font settings.
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
appearance.set_theme(dark_theme(), ctx);
});
let banner = ctx.add_model(|_| BannerState::default());
let (window_id, root) = ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
|_| RootTuiView::new(),
);
match spawn_tui_driver(ctx, window_id, root.clone()) {
Ok(driver) => {
let session = ctx.add_singleton_model(|_| TuiSession {
driver,
manager: None,
});
if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) {
// Already authenticated at mount: create the session now.
create_terminal_session_after_login(&session, &root, &banner, ctx);
} else {
// Otherwise wait for login to complete and create it then.
let session_for_login = session.clone();
let root_for_login = root.clone();
let banner_for_login = banner.clone();
let login_model = TuiLoginModel::handle(ctx);
ctx.subscribe_to_model(&login_model, move |_, _, ctx| {
if matches!(TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::LoggedIn) {
create_terminal_session_after_login(
&session_for_login,
&root_for_login,
&banner_for_login,
ctx,
);
}
});
}
}
Err(error) => {
log::error!("failed to start transcript TUI: {error}");
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(error.into())));
}
}
}
/// Creates and retains the terminal manager after login.
fn create_terminal_session_after_login(
session: &ModelHandle<TuiSession>,
root: &ViewHandle<RootTuiView>,
banner: &ModelHandle<BannerState>,
ctx: &mut AppContext,
) {
if session.read(ctx, |session, _| session.manager.is_some()) {
return;
}
let root = root.clone();
let manager = LocalTtyTerminalManager::<TuiTerminalSessionView>::create_tui_model(
std::env::current_dir().ok(),
HashMap::<OsString, OsString>::from_iter(std::env::vars_os()),
IsSharedSessionCreator::No,
None,
banner.clone(),
Vector2F::new(120., 24.),
None,
None,
ctx,
move |surface_init, ctx| {
let surface = root.update(ctx, |root, ctx| {
let surface = root.create_terminal_session(surface_init, ctx);
// Re-render the root so it swaps the login placeholder for the session.
ctx.notify();
surface
});
TerminalSurfaceResult {
surface,
post_wire: |_manager: &mut LocalTtyTerminalManager<TuiTerminalSessionView>,
_surface: &ViewHandle<TuiTerminalSessionView>,
_ctx: &mut AppContext| {},
}
},
);
session.update(ctx, |session, ctx| {
session.manager = Some(manager.manager);
ctx.notify();
});
}
+231
View File
@@ -0,0 +1,231 @@
//! Simple terminal block rendering for the TUI transcript.
use std::ops::Range;
use std::sync::Arc;
use parking_lot::FairMutex;
use warp::tui_export::{Block, BlockGrid, BlockId, BlockList, TerminalColorList, TerminalModel};
use warp_terminal::model::ansi::Color;
use warp_terminal::model::grid::cell::{Cell, Flags};
use warp_terminal::model::grid::Dimensions as _;
use warpui_core::elements::tui::{
Color as TuiColor, Modifier, TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext, TuiRect,
TuiSize, TuiStyle,
};
use warpui_core::AppContext;
/// Paints a pre-clipped row window from one terminal block.
///
/// This is a bespoke [`TuiElement`], unlike agent blocks which compose generic
/// `TuiText`/`TuiContainer`: terminal cells each carry their own fg/bg/flags,
/// which no generic single-style text element can express, and a block can be
/// thousands of rows — painting only the visible slice into the buffer avoids
/// materializing a huge element tree per frame.
pub(super) struct TerminalBlockVisibleRowsElement {
model: Arc<FairMutex<TerminalModel>>,
block_id: BlockId,
visible_rows: Range<usize>,
width: u16,
}
impl TerminalBlockVisibleRowsElement {
/// Creates a terminal block element for a visible row window.
pub(super) fn new(
model: Arc<FairMutex<TerminalModel>>,
block_id: BlockId,
visible_rows: Range<usize>,
width: u16,
) -> Self {
Self {
model,
block_id,
visible_rows,
width,
}
}
}
impl TuiElement for TerminalBlockVisibleRowsElement {
fn layout(
&mut self,
constraint: TuiConstraint,
_ctx: &mut TuiLayoutContext,
_app: &AppContext,
) -> TuiSize {
constraint.clamp(TuiSize::new(
constraint.max.width,
self.visible_rows
.end
.saturating_sub(self.visible_rows.start)
.min(usize::from(u16::MAX)) as u16,
))
}
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {
let model = self.model.lock();
let colors = model.colors();
let Some(block) = model.block_list().block_with_id(&self.block_id) else {
return;
};
// A block stacks its prompt/command grid above its output grid; each call
// paints only that grid's rows overlapping this element's visible window,
// positioned within `area`, so the two grids don't overlap.
let max_width = self.width.min(area.width);
if !block.should_hide_command_grid() {
render_grid_rows(
block.prompt_and_command_grid(),
block
.prompt_and_command_grid_offset()
.as_f64()
.ceil()
.max(0.0) as usize,
self.visible_rows.clone(),
max_width,
area,
buffer,
&colors,
);
}
if !block.should_hide_output_grid() {
render_grid_rows(
block.output_grid(),
block.output_grid_offset().as_f64().ceil().max(0.0) as usize,
self.visible_rows.clone(),
max_width,
area,
buffer,
&colors,
);
}
}
}
/// Returns whether the TUI transcript should include this terminal block.
pub(super) fn should_render_terminal_block(block: &Block, block_list: &BlockList) -> bool {
block.is_visible(block_list.agent_view_state()) && (block.started() || block.finished())
}
/// Paints consecutive displayed rows of one grid starting at `*y`, advancing
/// `y` past each row drawn and stopping at the bottom of `area`.
fn render_displayed_rows(
block_grid: &BlockGrid,
displayed_rows: Range<usize>,
max_width: u16,
area: TuiRect,
buffer: &mut TuiBuffer,
colors: &TerminalColorList,
y: &mut u16,
) {
let grid = block_grid.grid_handler();
let end = displayed_rows.end.min(block_grid.len_displayed());
for displayed_row in displayed_rows.start.min(end)..end {
if *y >= area.bottom() {
break;
}
let original_row = grid.maybe_translate_row_from_displayed_to_original(displayed_row);
let Some(row) = grid.row(original_row) else {
continue;
};
for column in 0..grid.columns().min(usize::from(max_width)) {
let cell = &row[column];
if let Some(buffer_cell) = buffer.cell_mut((area.x.saturating_add(column as u16), *y)) {
buffer_cell
.set_symbol(&sanitized_symbol(cell))
.set_style(cell_to_style(cell, colors));
}
}
*y = (*y).saturating_add(1);
}
}
/// Paints the rows of one grid that fall within the element's visible window.
///
/// `grid_start_row` is where this grid begins relative to the top of the block
/// (the command grid starts at 0; the output grid starts below it). Only the
/// intersection of the grid's rows with `visible_rows` is drawn, offset within
/// `area` so it lands at the correct vertical position.
fn render_grid_rows(
block_grid: &BlockGrid,
grid_start_row: usize,
visible_rows: Range<usize>,
max_width: u16,
area: TuiRect,
buffer: &mut TuiBuffer,
colors: &TerminalColorList,
) {
let grid_end_row = grid_start_row.saturating_add(block_grid.len_displayed());
let visible_start = visible_rows.start.max(grid_start_row);
let visible_end = visible_rows.end.min(grid_end_row);
if visible_start >= visible_end {
return;
}
let displayed_rows =
visible_start.saturating_sub(grid_start_row)..visible_end.saturating_sub(grid_start_row);
let y_offset = visible_start.saturating_sub(visible_rows.start);
let mut y = area
.y
.saturating_add(y_offset.min(usize::from(u16::MAX)) as u16);
render_displayed_rows(
block_grid,
displayed_rows,
max_width,
area,
buffer,
colors,
&mut y,
);
}
fn cell_to_color(color: &Color, colors: &TerminalColorList) -> TuiColor {
match color {
Color::Named(named) => {
let color = &colors[named.into_color_index()];
TuiColor::Rgb(color.r, color.g, color.b)
}
Color::Spec(color) => TuiColor::Rgb(color.r, color.g, color.b),
Color::Indexed(index) => {
let color = &colors[*index as usize];
TuiColor::Rgb(color.r, color.g, color.b)
}
}
}
fn cell_to_style(cell: &Cell, colors: &TerminalColorList) -> TuiStyle {
let mut style = TuiStyle::default()
.fg(cell_to_color(&cell.fg, colors))
.bg(cell_to_color(&cell.bg, colors));
if cell.flags.contains(Flags::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
if cell.flags.contains(Flags::ITALIC) {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.flags.contains(Flags::UNDERLINE) || cell.flags.contains(Flags::DOUBLE_UNDERLINE) {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.flags.contains(Flags::INVERSE) {
style = style.add_modifier(Modifier::REVERSED);
}
if cell.flags.contains(Flags::DIM) {
style = style.add_modifier(Modifier::DIM);
}
if cell.flags.contains(Flags::HIDDEN) {
style = style.add_modifier(Modifier::HIDDEN);
}
if cell.flags.contains(Flags::STRIKEOUT) {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
style
}
fn sanitized_symbol(cell: &Cell) -> String {
let content = cell.content_for_display().to_string();
if content.is_empty() || content.chars().any(char::is_control) {
" ".to_owned()
} else {
content
}
}
@@ -0,0 +1,218 @@
//! Authenticated terminal-session TUI surface.
use warp::editor::CodeEditorModel;
use warp::tui_export::{
ActiveSession, AgentViewEntryOrigin, Appearance, BlocklistAIActionModel,
BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, ConversationSelection,
ConversationSelectionHandle, GetRelevantFilesController, ModelEvent, PtyIntent, PtyIntentEvent,
TerminalSurface, TerminalSurfaceInit,
};
use warp_core::ui::theme::Fill as ThemeFill;
use warpui::SingletonEntity;
use warpui_core::elements::tui::{
Color, TuiChildView, TuiColumn, TuiConstrainedBox, TuiContainer, TuiElement, TuiStyle,
};
use warpui_core::elements::Fill as CoreFill;
use warpui_core::{
AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle,
};
use crate::conversation_selection::TuiConversationSelection;
use crate::input::{TuiInputView, TuiInputViewEvent};
use crate::transcript_view::TuiTranscriptView;
/// Width used before the first layout pass pushes the real terminal width into the editor.
const INITIAL_INPUT_WIDTH: u16 = 80;
const MAX_INPUT_TEXT_ROWS: u16 = 6;
/// This TUI surface does not emit direct PTY intents.
pub(crate) struct TuiTerminalSessionEvent;
impl PtyIntentEvent for TuiTerminalSessionEvent {
fn pty_intent(&self) -> Option<PtyIntent> {
None
}
}
/// The authenticated terminal/session surface rendered inside [`RootTuiView`].
pub(crate) struct TuiTerminalSessionView {
transcript: ViewHandle<TuiTranscriptView>,
input_view: ViewHandle<TuiInputView>,
conversation_selection: ConversationSelectionHandle,
ai_controller: ModelHandle<BlocklistAIController>,
}
impl TuiTerminalSessionView {
/// Builds the transcript-capable terminal surface for a manager-backed session.
pub(crate) fn new(surface_init: TerminalSurfaceInit, ctx: &mut ViewContext<Self>) -> Self {
let TerminalSurfaceInit {
model,
sessions,
model_events,
wakeups_rx,
..
} = surface_init;
let terminal_surface_id: EntityId = ctx.view_id();
let active_session =
ctx.add_model(|ctx| ActiveSession::new(sessions.clone(), model_events.clone(), ctx));
let conversation_selection = ctx.add_model(|ctx| {
Box::new(TuiConversationSelection::new(terminal_surface_id, ctx))
as Box<dyn ConversationSelection>
});
let context_model = ctx.add_model(|ctx| {
BlocklistAIContextModel::new(
sessions,
&model_events,
model.clone(),
terminal_surface_id,
conversation_selection.clone(),
ctx,
)
});
let ai_input_model = ctx.add_model(|ctx| {
BlocklistAIInputModel::new(
model.clone(),
conversation_selection.clone(),
context_model.clone(),
terminal_surface_id,
ctx,
)
});
let get_relevant_files_controller = ctx.add_model(GetRelevantFilesController::new);
let action_model = ctx.add_model(|ctx| {
BlocklistAIActionModel::new(
model.clone(),
active_session.clone(),
&model_events,
get_relevant_files_controller,
terminal_surface_id,
ctx,
)
});
let ai_controller = ctx.add_model(|ctx| {
BlocklistAIController::new(
ai_input_model,
context_model,
conversation_selection.clone(),
action_model,
active_session,
model.clone(),
terminal_surface_id,
ctx,
)
});
let transcript = ctx.add_typed_action_tui_view(|ctx| {
TuiTranscriptView::new(terminal_surface_id, model.clone(), ctx)
});
let input_editor_model =
ctx.add_model(|ctx| CodeEditorModel::new_tui(INITIAL_INPUT_WIDTH, ctx));
let input_view =
ctx.add_typed_action_tui_view(move |ctx| TuiInputView::new(input_editor_model, ctx));
ctx.subscribe_to_view(&input_view, |view, _, event, ctx| match event {
TuiInputViewEvent::Submitted(prompt) => {
let prompt = prompt.trim().to_owned();
if !prompt.is_empty() {
view.send_prompt(prompt, ctx);
ctx.notify();
}
}
});
// These events update block metadata or grids the transcript reads.
// PTY output redraws are driven by `wakeups_rx` below.
ctx.subscribe_to_model(&model_events, |_, _, event, ctx| match event {
ModelEvent::BlockCompleted(_)
| ModelEvent::AfterBlockStarted { .. }
| ModelEvent::BlockMetadataReceived(_)
| ModelEvent::BlockWorkingDirectoryUpdated(_)
| ModelEvent::BackgroundBlockStarted
| ModelEvent::TerminalClear
| ModelEvent::PromptUpdated
| ModelEvent::Typeahead
| ModelEvent::Handler(_)
| ModelEvent::FinishUpdate(_) => ctx.notify(),
_ => {}
});
ctx.spawn_stream_local(wakeups_rx, |_, _, ctx| ctx.notify(), |_, _| {});
ctx.focus_self();
Self {
transcript,
input_view,
conversation_selection,
ai_controller,
}
}
/// Sends a prompt to the selected conversation, creating one if needed.
fn send_prompt(&mut self, prompt: String, ctx: &mut ViewContext<Self>) {
let conversation_id = match self
.conversation_selection
.as_ref(ctx)
.selected_conversation_id(ctx)
{
Some(conversation_id) => conversation_id,
None => match self.conversation_selection.update(ctx, |selection, ctx| {
selection.try_start_new_conversation(AgentViewEntryOrigin::Tui, ctx)
}) {
Ok(conversation_id) => conversation_id,
Err(error) => {
log::error!("Failed to create TUI conversation: {error:#}");
return;
}
},
};
self.ai_controller.update(ctx, |controller, ctx| {
controller.send_user_query_in_conversation(prompt, conversation_id, None, ctx);
});
}
}
impl Entity for TuiTerminalSessionView {
type Event = TuiTerminalSessionEvent;
}
impl TuiView for TuiTerminalSessionView {
fn ui_name() -> &'static str {
"TuiTerminalSessionView"
}
fn child_view_ids(&self, _ctx: &AppContext) -> Vec<EntityId> {
vec![self.transcript.id(), self.input_view.id()]
}
fn render(&self, ctx: &AppContext) -> Box<dyn TuiElement> {
let theme = Appearance::as_ref(ctx).theme();
let border_color: Color =
CoreFill::from(ThemeFill::from(theme.terminal_colors().normal.cyan)).into();
let input_box = TuiConstrainedBox::new(
TuiContainer::new(TuiChildView::new(&self.input_view))
.with_border_style(TuiStyle::default().fg(border_color)),
)
.with_max_rows(MAX_INPUT_TEXT_ROWS + 2);
TuiContainer::new(
TuiColumn::new()
.flex_child(TuiChildView::new(&self.transcript))
.child(input_box),
)
.with_padding(2)
.finish()
}
}
impl TypedActionView for TuiTerminalSessionView {
type Action = ();
}
impl TerminalSurface for TuiTerminalSessionView {
fn on_shell_determined(&mut self, ctx: &mut ViewContext<Self>) {
ctx.notify();
}
fn on_pty_spawn_failed(&mut self, error: anyhow::Error, ctx: &mut ViewContext<Self>) {
log::error!("TUI PTY spawn failed: {error:#}");
ctx.notify();
}
}
+278
View File
@@ -0,0 +1,278 @@
//! The production-shaped TUI transcript over canonical terminal block-list order.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use parking_lot::FairMutex;
use warp::tui_export::{
should_show_task_in_blocklist, AIAgentExchangeId, AIBlockModelImpl, AIConversationId,
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, RichContentItem, RichContentType,
TerminalModel,
};
use warpui_core::elements::tui::{
TuiElement, TuiScrollable, TuiViewportVerticalAlignment, TuiViewportedList,
TuiViewportedListState,
};
use warpui_core::{
AppContext, Entity, EntityId, SingletonEntity, TuiView, TypedActionView, ViewContext,
};
use super::agent_block::TuiAIBlock;
use super::tui_block_list_viewport_source::{AgentBlockRegistry, TuiBlockListViewportSource};
/// TUI transcript view over one terminal surface's canonical block-list order.
pub(super) struct TuiTranscriptView {
terminal_surface_id: EntityId,
model: Arc<FairMutex<TerminalModel>>,
agent_blocks: AgentBlockRegistry,
viewport: TuiViewportedListState,
}
impl TuiTranscriptView {
/// Creates a transcript view for one terminal surface.
pub(super) fn new(
terminal_surface_id: EntityId,
model: Arc<FairMutex<TerminalModel>>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|view, _, event, ctx| view.handle_history_event(event, ctx),
);
Self {
terminal_surface_id,
model,
agent_blocks: Rc::new(RefCell::new(HashMap::new())),
viewport: TuiViewportedListState::new_at_end(),
}
}
fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ViewContext<Self>,
) {
if event
.terminal_surface_id()
.is_some_and(|id| id != self.terminal_surface_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::AppendedExchange {
exchange_id,
task_id,
conversation_id,
is_hidden,
..
} => {
if *is_hidden {
return;
}
let should_show = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.and_then(|conversation| conversation.get_task(task_id))
.is_some_and(should_show_task_in_blocklist);
if should_show {
self.insert_agent_block(*conversation_id, *exchange_id, ctx);
}
}
BlocklistAIHistoryEvent::UpdatedStreamingExchange { exchange_id, .. } => {
self.mark_exchange_dirty(*exchange_id, ctx);
}
BlocklistAIHistoryEvent::ReassignedExchange {
exchange_id,
new_conversation_id,
..
} => self.reassign_exchange(*exchange_id, *new_conversation_id, ctx),
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces {
conversation_id,
..
} => self.remove_conversation(*conversation_id, ctx),
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
self.clear_agent_blocks(ctx);
}
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. }
| BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
| BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. }
| BlocklistAIHistoryEvent::NewConversationRequestComplete { .. }
| BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. }
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. }
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {}
}
}
/// Returns the view id of the agent block rendering `exchange_id`, if any.
fn view_id_for_exchange(
&self,
exchange_id: AIAgentExchangeId,
ctx: &AppContext,
) -> Option<EntityId> {
self.agent_blocks
.borrow()
.iter()
.find_map(|(view_id, view)| {
(view.as_ref(ctx).exchange_id() == exchange_id).then_some(*view_id)
})
}
fn insert_agent_block(
&mut self,
conversation_id: AIConversationId,
exchange_id: AIAgentExchangeId,
ctx: &mut ViewContext<Self>,
) {
if self.view_id_for_exchange(exchange_id, ctx).is_some() {
return;
}
let Ok(block_model) =
AIBlockModelImpl::<TuiAIBlock>::new(exchange_id, conversation_id, false, false, ctx)
else {
log::warn!(
"Failed to create TUI model for AI block on AppendedExchange: {exchange_id:?}"
);
return;
};
let block_model = Rc::new(block_model);
let view = ctx.add_tui_view(|_| TuiAIBlock::new(conversation_id, exchange_id, block_model));
let view_id = view.id();
self.agent_blocks.borrow_mut().insert(view_id, view);
self.model.lock().block_list_mut().append_rich_content(
RichContentItem::new(Some(RichContentType::AIBlock), view_id, None, false),
false,
);
ctx.notify();
}
fn mark_exchange_dirty(&mut self, exchange_id: AIAgentExchangeId, ctx: &mut ViewContext<Self>) {
if let Some(view_id) = self.view_id_for_exchange(exchange_id, ctx) {
self.model
.lock()
.block_list_mut()
.mark_rich_content_dirty(view_id);
ctx.notify();
}
}
fn reassign_exchange(
&mut self,
exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let Some(view_id) = self.view_id_for_exchange(exchange_id, ctx) else {
return;
};
let Some(agent_block) = self.agent_blocks.borrow().get(&view_id).cloned() else {
return;
};
let Ok(block_model) =
AIBlockModelImpl::<TuiAIBlock>::new(exchange_id, conversation_id, false, false, ctx)
else {
log::warn!(
"Failed to create reassigned TUI model for AI block on ReassignedExchange: {exchange_id:?}"
);
return;
};
agent_block.update(ctx, |view, _| {
view.replace_model(conversation_id, Rc::new(block_model))
});
self.model
.lock()
.block_list_mut()
.mark_rich_content_dirty(view_id);
ctx.notify();
}
fn remove_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let view_ids = self
.agent_blocks
.borrow()
.iter()
.filter_map(|(view_id, view)| {
(view.as_ref(ctx).conversation_id() == conversation_id).then_some(*view_id)
})
.collect::<Vec<_>>();
for view_id in view_ids {
self.agent_blocks.borrow_mut().remove(&view_id);
self.model
.lock()
.block_list_mut()
.remove_rich_content(view_id);
}
ctx.notify();
}
fn clear_agent_blocks(&mut self, ctx: &mut ViewContext<Self>) {
let view_ids = self
.agent_blocks
.borrow()
.keys()
.copied()
.collect::<Vec<_>>();
self.agent_blocks.borrow_mut().clear();
let mut model = self.model.lock();
for view_id in view_ids {
model.block_list_mut().remove_rich_content(view_id);
}
ctx.notify();
}
}
impl Entity for TuiTranscriptView {
type Event = ();
}
impl TuiView for TuiTranscriptView {
fn ui_name() -> &'static str {
"TuiTranscriptView"
}
fn child_view_ids(&self, _app: &AppContext) -> Vec<EntityId> {
self.agent_blocks.borrow().keys().copied().collect()
}
fn render(&self, _app: &AppContext) -> Box<dyn TuiElement> {
let source = TuiBlockListViewportSource::new(self.model.clone(), self.agent_blocks.clone());
TuiScrollable::new(
TuiViewportedList::new(self.viewport.clone(), source)
.with_vertical_alignment(TuiViewportVerticalAlignment::GrowFromBottom),
)
.finish()
}
}
impl TypedActionView for TuiTranscriptView {
type Action = ();
}
#[cfg(test)]
#[path = "transcript_view_tests.rs"]
mod tests;
@@ -0,0 +1,292 @@
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()
}
@@ -0,0 +1,344 @@
//! TUI viewport source backed by the canonical terminal block list.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::rc::Rc;
use std::sync::Arc;
use parking_lot::FairMutex;
use sum_tree::SeekBias;
#[cfg(test)]
use warp::tui_export::TotalIndex;
use warp::tui_export::{BlockHeight, BlockHeightItem, BlockHeightSummary, BlockId, TerminalModel};
use warpui::{EntityId, ViewHandle};
use warpui_core::elements::tui::{
TuiElement, TuiViewportContent, TuiViewportWindow, TuiViewportedElement, TuiVisibleViewportItem,
};
use warpui_core::{AppContext, TuiView};
use super::agent_block::TuiAIBlock;
use super::terminal_block::{should_render_terminal_block, TerminalBlockVisibleRowsElement};
pub(super) type AgentBlockRegistry = Rc<RefCell<HashMap<EntityId, ViewHandle<TuiAIBlock>>>>;
/// Extra rows above and below the viewport whose non-dirty agent blocks are
/// re-measured each frame, so near-off-screen reflow (e.g. a width change) is
/// reflected before windowing. Mirrors the GUI blocklist's overhang pass.
const OVERHANG_ROWS: usize = 20;
/// Stable identities used by TUI block-list viewport tests.
#[cfg(test)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum TuiBlockListViewportItemId {
TerminalBlock(BlockId),
AgentBlock(EntityId),
}
struct TuiBlockListVisibleItem {
origin_y: usize,
/// Full cached height from the canonical `BlockList`.
height: usize,
kind: TuiBlockListVisibleItemKind,
}
enum TuiBlockListVisibleItemKind {
TerminalBlock(BlockId),
AgentBlock(ViewHandle<TuiAIBlock>),
}
/// Adapts a terminal model's canonical block-list order for TUI viewporting.
#[derive(Clone)]
pub(super) struct TuiBlockListViewportSource {
model: Arc<FairMutex<TerminalModel>>,
agent_blocks: AgentBlockRegistry,
}
impl TuiBlockListViewportSource {
/// Creates a TUI viewport source over the canonical terminal model.
pub(super) fn new(
model: Arc<FairMutex<TerminalModel>>,
agent_blocks: AgentBlockRegistry,
) -> Self {
Self {
model,
agent_blocks,
}
}
/// Collects the agent-block view ids to measure this frame: the drained
/// dirty set (measured wherever they sit) plus every non-dirty agent block
/// whose row range intersects the viewport window padded by [`OVERHANG_ROWS`].
/// The overhang band catches reflow of near-off-screen blocks that were
/// never dirtied, so their heights are fresh before the window is computed.
fn agent_heights_to_measure(&self, window: TuiViewportWindow) -> HashSet<EntityId> {
let mut model = self.model.lock();
let mut view_ids = model.block_list_mut().take_dirty_rich_content_items();
let agent_blocks = self.agent_blocks.borrow();
let block_list = model.block_list();
let band_top = window.scroll_top.saturating_sub(OVERHANG_ROWS);
let band_bottom = window
.scroll_top
.saturating_add(usize::from(window.viewport_height))
.saturating_add(OVERHANG_ROWS);
let mut cursor = block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.seek_clamped(&BlockHeight::from(band_top as f64), SeekBias::Left);
while let Some(item) = cursor.item() {
let item_top = cursor.start().height.as_f64().floor().max(0.0) as usize;
if item_top >= band_bottom {
break;
}
let item_bottom = item_top.saturating_add(item.height().as_f64().ceil() as usize);
if item_bottom > band_top {
if let BlockHeightItem::RichContent(rich_content) = item {
if !rich_content.should_hide && agent_blocks.contains_key(&rich_content.view_id)
{
view_ids.insert(rich_content.view_id);
}
}
}
cursor.next();
}
view_ids
}
/// Measures each agent block's wrapped height at `width`, returning heights
/// in the block list's native line unit.
fn measured_agent_heights(
&self,
view_ids: HashSet<EntityId>,
width: u16,
app: &AppContext,
) -> HashMap<EntityId, BlockHeight> {
let agent_blocks = self.agent_blocks.borrow();
view_ids
.into_iter()
.filter_map(|view_id| {
let view = agent_blocks.get(&view_id)?;
Some((
view_id,
BlockHeight::from(view.as_ref(app).desired_height(width, app).max(1) as f64),
))
})
.collect()
}
/// Writes measured rich-content heights back to the canonical block list.
/// Heights are already in the block list's native line unit (one line per
/// terminal row), so no pixel round-trip is needed.
fn write_line_heights(&self, line_heights: &HashMap<EntityId, BlockHeight>) {
if line_heights.is_empty() {
return;
}
self.model
.lock()
.block_list_mut()
.update_rich_content_heights_in_lines(line_heights);
}
fn visible_items_in_window(
&self,
window: TuiViewportWindow,
) -> (usize, Vec<TuiBlockListVisibleItem>) {
let model = self.model.lock();
let block_list = model.block_list();
let agent_blocks = self.agent_blocks.borrow();
let viewport_bottom = window
.scroll_top
.saturating_add(usize::from(window.viewport_height));
let mut visible_items = Vec::new();
let content_height = block_list
.block_heights()
.summary()
.height
.as_f64()
.ceil()
.max(0.0) as usize;
let mut cursor = block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.seek_clamped(&BlockHeight::from(window.scroll_top as f64), SeekBias::Left);
while let Some(item) = cursor.item() {
let item_top = cursor.start().height.as_f64().floor().max(0.0) as usize;
let item_bottom = item_top.saturating_add(item.height().as_f64().ceil() as usize);
if item_bottom <= window.scroll_top {
cursor.next();
continue;
}
if item_top >= viewport_bottom {
break;
}
let visible_item = match item {
BlockHeightItem::Block(_) => {
let height = item.height().as_f64().ceil().max(0.0) as usize;
let block = block_list.block_at(cursor.start().block_count.into());
block.and_then(|block| {
if height == 0 || !should_render_terminal_block(block, block_list) {
return None;
}
Some(TuiBlockListVisibleItem {
origin_y: item_top,
height,
kind: TuiBlockListVisibleItemKind::TerminalBlock(block.id().clone()),
})
})
}
BlockHeightItem::RichContent(item) => {
if item.should_hide {
None
} else if let Some(view) = agent_blocks.get(&item.view_id) {
let height = item.last_laid_out_height.as_f64().ceil().max(1.0) as usize;
Some(TuiBlockListVisibleItem {
origin_y: item_top,
height,
kind: TuiBlockListVisibleItemKind::AgentBlock(view.clone()),
})
} else {
None
}
}
BlockHeightItem::Gap(_)
| BlockHeightItem::RestoredBlockSeparator { .. }
| BlockHeightItem::InlineBanner { .. }
| BlockHeightItem::SubshellSeparator { .. } => None,
};
if let Some(item) = visible_item {
let rendered_item_bottom = item_top.saturating_add(item.height);
if rendered_item_bottom > window.scroll_top && item_top < viewport_bottom {
visible_items.push(item);
}
}
cursor.next();
}
(content_height, visible_items)
}
#[cfg(test)]
pub(super) fn item_ids_for_test(&self) -> Vec<TuiBlockListViewportItemId> {
let model = self.model.lock();
let block_list = model.block_list();
let agent_blocks = self.agent_blocks.borrow();
let mut item_ids = Vec::new();
let mut cursor = block_list
.block_heights()
.cursor::<TotalIndex, BlockHeightSummary>();
cursor.seek(&TotalIndex(0), SeekBias::Right);
while let Some(item) = cursor.item() {
match item {
BlockHeightItem::Block(_) => {
let block = block_list.block_at(cursor.start().block_count.into());
if let Some(block) =
block.filter(|block| should_render_terminal_block(block, block_list))
{
item_ids.push(TuiBlockListViewportItemId::TerminalBlock(
block.id().clone(),
));
}
}
BlockHeightItem::RichContent(item)
if !item.should_hide && agent_blocks.contains_key(&item.view_id) =>
{
item_ids.push(TuiBlockListViewportItemId::AgentBlock(item.view_id));
}
BlockHeightItem::RichContent(_)
| BlockHeightItem::Gap(_)
| BlockHeightItem::RestoredBlockSeparator { .. }
| BlockHeightItem::InlineBanner { .. }
| BlockHeightItem::SubshellSeparator { .. } => {}
}
cursor.next();
}
item_ids
}
}
impl TuiViewportedElement for TuiBlockListViewportSource {
fn visible_items(
&self,
window: TuiViewportWindow,
available_width: u16,
app: &AppContext,
) -> TuiViewportContent {
// Refresh cached heights before windowing: the dirty set plus a band of
// near-off-screen agent blocks (see `agent_heights_to_measure`).
let view_ids_to_measure = self.agent_heights_to_measure(window);
let heights = self.measured_agent_heights(view_ids_to_measure, available_width, app);
self.write_line_heights(&heights);
let (content_height, visible_items) = self.visible_items_in_window(window);
let items = visible_items
.into_iter()
.map(|item| item.render(&self.model, window, available_width, app))
.collect();
TuiViewportContent {
content_height,
items,
}
}
}
impl TuiBlockListVisibleItem {
fn visible_rows(&self, window: TuiViewportWindow) -> Range<usize> {
let item_top = self.origin_y;
let item_bottom = item_top.saturating_add(self.height);
let visible_top = item_top.max(window.scroll_top);
let visible_bottom = item_bottom.min(
window
.scroll_top
.saturating_add(usize::from(window.viewport_height)),
);
visible_top.saturating_sub(item_top)..visible_bottom.saturating_sub(item_top)
}
fn render(
self,
model: &Arc<FairMutex<TerminalModel>>,
window: TuiViewportWindow,
available_width: u16,
app: &AppContext,
) -> TuiVisibleViewportItem {
let visible_rows = self.visible_rows(window);
// Terminal blocks get pre-sliced below; rich content stays whole and lets `TuiClipped`
// handle any partial visibility.
let origin_y = match &self.kind {
TuiBlockListVisibleItemKind::TerminalBlock(_) => {
self.origin_y.saturating_add(visible_rows.start)
}
TuiBlockListVisibleItemKind::AgentBlock(_) => self.origin_y,
};
TuiVisibleViewportItem {
origin_y,
element: self.render_element(model, visible_rows, available_width, app),
}
}
fn render_element(
self,
model: &Arc<FairMutex<TerminalModel>>,
visible_rows: Range<usize>,
width: u16,
app: &AppContext,
) -> Box<dyn TuiElement> {
match self.kind {
TuiBlockListVisibleItemKind::TerminalBlock(block_id) => {
debug_assert!(visible_rows.end <= self.height);
TerminalBlockVisibleRowsElement::new(model.clone(), block_id, visible_rows, width)
.finish()
}
TuiBlockListVisibleItemKind::AgentBlock(view) => view.as_ref(app).render(app),
}
}
}
#[cfg(test)]
#[path = "tui_block_list_viewport_source_tests.rs"]
mod tests;
@@ -0,0 +1,344 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use parking_lot::FairMutex;
use warp::tui_export::{
AIAgentExchangeId, AIAgentInput, AIBlockModel, AIBlockOutputStatus, AIConversationId,
AIRequestType, Appearance, BlockHeightItem, LLMId, OutputStatusUpdateCallback, RichContentItem,
RichContentType, ServerOutputId, TerminalModel, UserQueryMode,
};
use warpui::platform::WindowStyle;
use warpui::{AddWindowOptions, EntityId, EntityIdMap, ViewHandle};
use warpui_core::elements::tui::{
TuiConstraint, TuiElement, TuiLayoutContext, TuiSize, TuiText, TuiViewportContent,
TuiViewportWindow, TuiViewportedElement,
};
use warpui_core::{App, AppContext, Entity, TuiView, TypedActionView, ViewContext};
use super::{AgentBlockRegistry, TuiBlockListViewportItemId, TuiBlockListViewportSource};
use crate::agent_block::TuiAIBlock;
use crate::terminal_block::should_render_terminal_block;
#[test]
fn tui_block_list_viewport_source_uses_canonical_block_list_order() {
let mut model = TerminalModel::mock(None, None);
model.simulate_block("echo 1", "1\r\n");
model.simulate_block("echo 2", "2\r\n");
let expected = model
.block_list()
.blocks()
.iter()
.filter(|block| should_render_terminal_block(block, model.block_list()))
.map(|block| TuiBlockListViewportItemId::TerminalBlock(block.id().clone()))
.collect::<Vec<_>>();
let source = TuiBlockListViewportSource::new(
Arc::new(FairMutex::new(model)),
AgentBlockRegistry::new(RefCell::new(HashMap::new())),
);
let actual = source.item_ids_for_test();
assert_eq!(actual, expected);
}
#[test]
fn tui_block_list_viewport_source_slices_terminal_blocks_to_visible_rows() {
App::test((), |app| async move {
app.read(|app| {
let mut model = TerminalModel::mock(None, None);
model.simulate_block("printf", "one\r\ntwo\r\nthree\r\n");
let source = TuiBlockListViewportSource::new(
Arc::new(FairMutex::new(model)),
AgentBlockRegistry::new(RefCell::new(HashMap::new())),
);
let content = source.visible_items(
TuiViewportWindow {
scroll_top: 1,
viewport_height: 1,
},
80,
app,
);
assert_eq!(content.items.len(), 1);
let mut item = content.items.into_iter().next().unwrap();
assert_eq!(item.origin_y, 1);
let mut rendered_views = EntityIdMap::default();
let mut layout_ctx = TuiLayoutContext {
rendered_views: &mut rendered_views,
};
let size = item.element.layout(
TuiConstraint::loose(TuiSize::new(80, u16::MAX)),
&mut layout_ctx,
app,
);
assert_eq!(size.height, 1);
});
});
}
#[test]
fn tui_agent_rich_content_stays_visible_without_gui_agent_view_state() {
let mut model = TerminalModel::mock(None, None);
let view_id = EntityId::new();
model.block_list_mut().append_rich_content(
RichContentItem::new(Some(RichContentType::AIBlock), view_id, None, false),
false,
);
model
.block_list_mut()
.update_rich_content_heights(&HashMap::from([(view_id, 3.0)]));
let rich_content = model
.block_list()
.block_heights()
.cursor::<(), ()>()
.find_map(|item| match item {
BlockHeightItem::RichContent(item) if item.view_id == view_id => Some(item),
BlockHeightItem::Block(_)
| BlockHeightItem::Gap(_)
| BlockHeightItem::RestoredBlockSeparator { .. }
| BlockHeightItem::InlineBanner { .. }
| BlockHeightItem::SubshellSeparator { .. }
| BlockHeightItem::RichContent(_) => None,
})
.expect("TUI agent rich content should remain in the canonical block list");
assert!(!rich_content.should_hide);
assert!(rich_content.last_laid_out_height.as_f64() > 0.0);
}
#[test]
fn tui_agent_overhang_remeasures_visible_non_dirty_height() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| Appearance::mock());
let (source, model, agent_block) = seeded_agent_block_source(&mut app, 0, 99.0);
let expected = app.read(|app| agent_block.as_ref(app).desired_height(80, app) as f64);
// The visible block is re-measured during `visible_items`, so its height
// is corrected before windowing without any post-layout pass.
let content = request_top_window(&app, &source, 10);
assert_ne!(expected, 99.0);
assert_eq!(content.content_height, expected as usize);
assert_eq!(
rich_content_height(&model, agent_block.id()),
Some(expected)
);
});
}
#[test]
fn tui_agent_overhang_remeasures_near_offscreen_non_dirty_height() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| Appearance::mock());
// A short terminal block pushes the agent block below a 1-row viewport
// but within the overhang band.
let (source, model, agent_block) = seeded_agent_block_source(&mut app, 3, 99.0);
let expected = app.read(|app| agent_block.as_ref(app).desired_height(80, app) as f64);
request_top_window(&app, &source, 1);
assert_ne!(expected, 99.0);
assert_eq!(
rich_content_height(&model, agent_block.id()),
Some(expected)
);
});
}
#[test]
fn tui_agent_beyond_overhang_keeps_stale_height() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| Appearance::mock());
// A tall terminal block pushes the agent block beyond the overhang band.
let (source, model, agent_block) = seeded_agent_block_source(&mut app, 30, 7.0);
request_top_window(&app, &source, 1);
// Beyond OVERHANG_ROWS: not re-measured, so the stale height is retained.
assert_eq!(rich_content_height(&model, agent_block.id()), Some(7.0));
});
}
/// Builds a source over one registered agent block seeded with a stale,
/// non-dirty cached height. When `preceding_rows > 0`, a terminal block of that
/// many output rows precedes it, controlling how far below the top it sits.
fn seeded_agent_block_source(
app: &mut App,
preceding_rows: usize,
stale_height: f64,
) -> (
TuiBlockListViewportSource,
Arc<FairMutex<TerminalModel>>,
ViewHandle<TuiAIBlock>,
) {
let mut model = TerminalModel::mock(None, None);
if preceding_rows > 0 {
model.simulate_block("printf", &"x\r\n".repeat(preceding_rows));
}
let terminal_model = Arc::new(FairMutex::new(model));
let agent_block = add_agent_block(app, "hello world from rust");
let view_id = agent_block.id();
{
let mut model = terminal_model.lock();
model.block_list_mut().append_rich_content(
RichContentItem::new(Some(RichContentType::AIBlock), view_id, None, false),
false,
);
// Clear the dirty flag and seed a stale height so only re-measurement
// (not the dirty path) can correct it.
model.block_list_mut().take_dirty_rich_content_items();
model
.block_list_mut()
.update_rich_content_heights(&HashMap::from([(view_id, stale_height)]));
}
let agent_blocks = AgentBlockRegistry::new(RefCell::new(HashMap::from([(
view_id,
agent_block.clone(),
)])));
let source = TuiBlockListViewportSource::new(terminal_model.clone(), agent_blocks);
(source, terminal_model, agent_block)
}
/// Runs the overhang + windowing pass for a top-anchored viewport at width 80.
fn request_top_window(
app: &App,
source: &TuiBlockListViewportSource,
viewport_height: u16,
) -> TuiViewportContent {
app.read(|app| {
source.visible_items(
TuiViewportWindow {
scroll_top: 0,
viewport_height,
},
80,
app,
)
})
}
/// Adds a `TuiAIBlock` backed by a single-query model in a fresh TUI
/// window and returns its handle.
fn add_agent_block(app: &mut App, query: &str) -> ViewHandle<TuiAIBlock> {
let query = query.to_owned();
app.update(|ctx| {
let (window_id, _) = ctx.add_tui_window(
AddWindowOptions {
window_style: WindowStyle::NotStealFocus,
..Default::default()
},
|_| TestHostView,
);
ctx.add_tui_view(window_id, move |_| {
TuiAIBlock::new(
AIConversationId::new(),
AIAgentExchangeId::new(),
Rc::new(QueryAgentBlockModel {
inputs: vec![query_input(&query)],
}),
)
})
})
}
struct QueryAgentBlockModel {
inputs: Vec<AIAgentInput>,
}
struct TestHostView;
impl Entity for TestHostView {
type Event = ();
}
impl TuiView for TestHostView {
fn ui_name() -> &'static str {
"TestHostView"
}
fn render(&self, _app: &AppContext) -> Box<dyn TuiElement> {
Box::new(TuiText::new(""))
}
}
impl TypedActionView for TestHostView {
type Action = ();
}
impl AIBlockModel for QueryAgentBlockModel {
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] {
&self.inputs
}
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
}
}
/// Builds one user-query input for wrapping-height tests.
fn query_input(query: &str) -> AIAgentInput {
AIAgentInput::UserQuery {
query: query.to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::default(),
running_command: None,
intended_agent: None,
}
}
/// Returns the cached rich-content height for a view ID.
fn rich_content_height(model: &Arc<FairMutex<TerminalModel>>, view_id: EntityId) -> Option<f64> {
model
.lock()
.block_list()
.block_heights()
.cursor::<(), ()>()
.find_map(|item| match item {
BlockHeightItem::RichContent(item) if item.view_id == view_id => {
Some(item.last_laid_out_height.as_f64())
}
BlockHeightItem::Block(_)
| BlockHeightItem::Gap(_)
| BlockHeightItem::RestoredBlockSeparator { .. }
| BlockHeightItem::InlineBanner { .. }
| BlockHeightItem::SubshellSeparator { .. }
| BlockHeightItem::RichContent(_) => None,
})
}
+77
View File
@@ -0,0 +1,77 @@
//! Small presentation helpers for the `warp-tui` front-end's TUI views.
use warpui_core::elements::tui::{Modifier, TuiColumn, TuiElement, TuiStyle, TuiText};
/// Vertically centers `content` by padding above and below with flex spacers.
pub(crate) fn centered(content: TuiColumn) -> Box<dyn TuiElement> {
TuiColumn::new()
.flex_child(TuiColumn::new())
.child(content)
.flex_child(TuiColumn::new())
.finish()
}
/// Placeholder shown while the user completes device-authorization login. The
/// verification URL/code are surfaced once known (the browser also auto-opens).
pub(crate) fn login_placeholder(
verification_uri: Option<&str>,
user_code: Option<&str>,
) -> Box<dyn TuiElement> {
let dim = TuiStyle::default().add_modifier(Modifier::DIM);
let mut content = TuiColumn::new().child(TuiText::new("Sign in to continue").truncate());
match (verification_uri, user_code) {
(Some(uri), Some(code)) => {
content = content
.child(
TuiText::new(format!("Open {uri} in your browser"))
.with_style(dim)
.truncate(),
)
.child(
TuiText::new(format!("and enter code: {code}"))
.with_style(dim)
.truncate(),
);
}
(Some(uri), None) => {
content = content.child(
TuiText::new(format!("Open {uri} in your browser"))
.with_style(dim)
.truncate(),
);
}
_ => {
content = content.child(
TuiText::new("Opening your browser…")
.with_style(dim)
.truncate(),
);
}
}
centered(content)
}
/// Placeholder shown between login completion and terminal session creation.
pub(crate) fn terminal_starting() -> Box<dyn TuiElement> {
let dim = TuiStyle::default().add_modifier(Modifier::DIM);
centered(
TuiColumn::new().child(
TuiText::new("Starting terminal…")
.with_style(dim)
.truncate(),
),
)
}
/// Placeholder shown when login fails; the user can quit with `Ctrl-C`.
pub(crate) fn login_failed(message: &str) -> Box<dyn TuiElement> {
let dim = TuiStyle::default().add_modifier(Modifier::DIM);
let content = TuiColumn::new()
.child(TuiText::new(format!("Login failed: {message}")).truncate())
.child(
TuiText::new("Press Ctrl-C to exit.")
.with_style(dim)
.truncate(),
);
centered(content)
}