first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 = ();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user