Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/ai/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3277,6 +3277,15 @@ impl AIAgentExchange {
self.finish_time
.map(|finish_time| finish_time.signed_duration_since(self.start_time))
}

/// The elapsed wall-clock time since this exchange started. `None` when
/// the clock skewed such that `start_time` is in the future.
pub fn time_since_start(&self) -> Option<Duration> {
Local::now()
.signed_duration_since(self.start_time)
.to_std()
.ok()
}
}

/// Request-level metadata propagated to the `AIAgentApi` that may be used for logging.
Expand Down
8 changes: 4 additions & 4 deletions crates/warp_tui/src/input/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ use warp_editor::render::model::{
use warp_editor::selection::TextUnit;
use warpui_core::elements::tui::{
Modifier, TuiBuffer, TuiConstraint, TuiContainer, TuiElement, TuiEvent, TuiEventContext,
TuiFlex, TuiHoverable, TuiLayoutContext, TuiParentElement, TuiPoint, TuiRect, TuiRectExt,
TuiSize, TuiStyle, TuiText,
TuiFlex, TuiHoverable, TuiLayoutContext, TuiPaintContext, TuiParentElement, TuiPoint, TuiRect,
TuiRectExt, TuiSize, TuiStyle, TuiText,
};
use warpui_core::elements::MouseStateHandle;
use warpui_core::keymap::macros::*;
Expand Down Expand Up @@ -1506,7 +1506,7 @@ impl TuiElement for TuiInputElement {
TuiSize::new(editor_width, content_size.height)
}

fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) {
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiPaintContext) {
self.column.render(area, buffer, ctx);
if !self.selected_spans.is_empty() {
let reversed = TuiStyle::default().add_modifier(Modifier::REVERSED);
Expand All @@ -1523,7 +1523,7 @@ impl TuiElement for TuiInputElement {
}
}

fn cursor_position(&self, area: TuiRect, _ctx: &mut TuiLayoutContext) -> Option<(u16, u16)> {
fn cursor_position(&self, area: TuiRect, _ctx: &mut TuiPaintContext) -> Option<(u16, u16)> {
if !self.cursor_visible
|| self.cursor_col >= area.width
|| self.cursor_row_in_view >= area.height
Expand Down
14 changes: 7 additions & 7 deletions crates/warp_tui/src/input/view_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use warp_core::semantic_selection::SemanticSelection;
use warp_editor::model::CoreEditorModel;
use warpui::EntityIdMap;
use warpui_core::elements::tui::{
TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPoint, TuiRect,
TuiSize,
TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext,
TuiPoint, TuiRect, TuiSize,
};
use warpui_core::event::{KeyEventDetails, ModifiersState};
use warpui_core::keymap::Keystroke;
Expand Down Expand Up @@ -72,7 +72,9 @@ fn cursor_and_height(
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);
let mut paint_ctx = TuiPaintContext::new(&mut rendered_views);
let cursor =
element.cursor_position(TuiRect::new(0, 0, size.width, size.height), &mut paint_ctx);
(cursor, size.height)
}

Expand Down Expand Up @@ -869,10 +871,8 @@ fn shell_mode_offsets_cursor_by_gutter() {
type_str(&view, ctx, "ab");
let (element, area) = laid_out_shell_row(&view, ctx);
let mut rendered_views = EntityIdMap::default();
let mut lctx = TuiLayoutContext {
rendered_views: &mut rendered_views,
};
assert_eq!(element.cursor_position(area, &mut lctx), Some((4, 0)));
let mut paint_ctx = TuiPaintContext::new(&mut rendered_views);
assert_eq!(element.cursor_position(area, &mut paint_ctx), Some((4, 0)));
});
});
}
Expand Down
1 change: 1 addition & 0 deletions crates/warp_tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod transient_hint;
mod tui_block_list_viewport_source;
mod tui_diff_storage;
mod tui_file_edits_view;
mod warping_indicator;

pub use root_view::RootTuiView;
pub use session::run;
6 changes: 3 additions & 3 deletions crates/warp_tui/src/terminal_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use warp_terminal::model::ansi::{Color, NamedColor};
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,
Color as TuiColor, Modifier, TuiBuffer, TuiConstraint, TuiElement, TuiLayoutContext,
TuiPaintContext, TuiRect, TuiSize, TuiStyle,
};
use warpui_core::AppContext;

Expand Down Expand Up @@ -61,7 +61,7 @@ impl TuiElement for TerminalBlockVisibleRowsElement {
))
}

fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiLayoutContext) {
fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiPaintContext) {
let model = self.model.lock();
let colors = model.colors();
let Some(block) = model.block_list().block_with_id(&self.block_id) else {
Expand Down
75 changes: 69 additions & 6 deletions crates/warp_tui/src/terminal_session_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ use warp::settings::{AISettings, AISettingsChangedEvent};
use warp::tui_export::{
AIAgentPtyWriteMode, ActiveSession, ActiveSessionEvent, AgentInteractionMetadata,
AgentViewEntryOrigin, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController,
BlocklistAIHistoryModel, BlocklistAIInputModel, CancellationReason, CommandExecutionSource,
ConversationSelection, ConversationSelectionHandle, ExecuteCommandEvent,
GetRelevantFilesController, LLMPreferences, LLMPreferencesEvent, ModelEvent, PtyIntent,
PtyIntentEvent, ShellCommandExecutorEvent, TerminalModel, TerminalSurface, TerminalSurfaceInit,
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputModel, CancellationReason,
CommandExecutionSource, ConversationSelection, ConversationSelectionHandle,
ExecuteCommandEvent, GetRelevantFilesController, LLMPreferences, LLMPreferencesEvent,
ModelEvent, PtyIntent, PtyIntentEvent, ShellCommandExecutorEvent, TerminalModel,
TerminalSurface, TerminalSurfaceInit,
};
use warp_editor::model::CoreEditorModel;
use warpui::SingletonEntity;
Expand All @@ -37,6 +38,7 @@ use crate::transcript_view::TuiTranscriptView;
use crate::transient_hint::TransientHint;
use crate::tui_builder::TuiUiBuilder;
use crate::ui::abbreviate_home_prefix;
use crate::warping_indicator::render_warping_indicator;

/// Width used before the first layout pass pushes the real terminal width into the editor.
const INITIAL_INPUT_WIDTH: u16 = 80;
Expand Down Expand Up @@ -213,6 +215,15 @@ impl TuiTerminalSessionView {
// The input box border color and the footer's shell-mode hint depend
// on the input mode.
ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| ctx.notify());
// The warping indicator between the transcript and the input box
// tracks the selected conversation: re-render when its status changes
// or an exchange starts (the elapsed counter's anchor) on this
// surface, and when the selected conversation changes.
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|view, _, event, ctx| view.handle_history_event(event, ctx),
);
ctx.subscribe_to_model(&conversation_selection, |_, _, _, ctx| ctx.notify());

// Bridge shared shell-tool executor events into terminal-manager PTY intents.
let shell_command_executor = action_model.as_ref(ctx).shell_command_executor(ctx);
Expand Down Expand Up @@ -275,6 +286,29 @@ impl TuiTerminalSessionView {
}
}

/// Re-renders on history events that can change the warping indicator:
/// the selected conversation's status changing, or an exchange starting
/// (which re-anchors the elapsed counter) on this surface.
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;
}
if matches!(
event,
BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
) {
ctx.notify();
}
}

/// Displays `text` in the footer's hint slot for the transient-hint
/// duration, then reverts to the persistent content.
fn show_transient_hint(&mut self, text: String, ctx: &mut ViewContext<Self>) {
Expand Down Expand Up @@ -596,9 +630,38 @@ impl TuiView for TuiTerminalSessionView {
// Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the
// fixed binding registered in [`Self::init`], so no element-level key
// handling is needed here.
let mut column = TuiFlex::column().flex_child(TuiChildView::new(&self.transcript).finish());

// While the selected conversation is in progress (the GUI warping
// indicator's core condition), the animated warping indicator sits
// between the transcript and the input box. Its elapsed counter is
// anchored to the latest exchange's start so animation survives
// element-tree rebuilds; the conversation's final status update
// re-renders the view without it.
let selected_conversation = self
.conversation_selection
.as_ref(ctx)
.selected_conversation_id(ctx)
.and_then(|conversation_id| {
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
});
if let Some(in_progress_conversation) =
selected_conversation.filter(|conversation| conversation.status().is_in_progress())
{
let warping_elapsed = in_progress_conversation
.latest_exchange()
.and_then(|exchange| exchange.time_since_start());
if let Some(elapsed) = warping_elapsed {
column = column.child(
TuiContainer::new(render_warping_indicator(elapsed, ctx))
.with_padding_top(1)
.finish(),
);
}
}

TuiContainer::new(
TuiFlex::column()
.flex_child(TuiChildView::new(&self.transcript).finish())
column
.child(input_box.finish())
.child(self.render_footer(ctx).finish())
.finish(),
Expand Down
5 changes: 3 additions & 2 deletions crates/warp_tui/src/transcript_view_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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,
TuiLayoutContext, TuiPaintContext, TuiRect, TuiSize,
};
use warpui_core::keymap::Keystroke;
use warpui_core::presenter::tui::TuiPresenter;
Expand Down Expand Up @@ -241,7 +241,8 @@ fn render_element(app: &App, element: &mut dyn TuiElement, area: TuiRect) -> Vec
app,
);
let mut buffer = TuiBuffer::empty(area);
element.render(area, &mut buffer, &mut ctx);
let mut paint_ctx = TuiPaintContext::new(&mut rendered_views);
element.render(area, &mut buffer, &mut paint_ctx);
buffer.to_lines()
})
}
Expand Down
24 changes: 24 additions & 0 deletions crates/warp_tui/src/tui_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! Composition and layout stay with the views and the element library; the
//! builder only owns styles.

use pathfinder_color::ColorU;
use warp::tui_export::Appearance;
use warp_core::ui::color::blend::Blend;
use warp_core::ui::theme::{Fill as ThemeFill, WarpTheme};
Expand Down Expand Up @@ -101,6 +102,29 @@ impl TuiUiBuilder {
TuiStyle::default().fg(cell_color(ThemeFill::Solid(self.warp_theme.ansi_fg_blue())))
}

/// The warping indicator's base fill (spinner glyph and "Warping" text):
/// the terminal palette's normal yellow, per the TUI design.
fn warping_base_fill(&self) -> ThemeFill {
ThemeFill::from(self.warp_theme.terminal_colors().normal.yellow)
}

/// The warping indicator's base color as a solid color, for per-glyph
/// shimmer lerping.
pub(crate) fn warping_base_color(&self) -> ColorU {
self.warping_base_fill().into_solid()
}

/// The peak color the "Warping" shimmer band lerps toward: the terminal
/// palette's bright white.
pub(crate) fn warping_shimmer_color(&self) -> ColorU {
ThemeFill::from(self.warp_theme.terminal_colors().bright.white).into_solid()
}

/// Style for the warping indicator's spinner glyph.
pub(crate) fn warping_spinner_style(&self) -> TuiStyle {
TuiStyle::default().fg(cell_color(self.warping_base_fill()))
}

/// Collapsible-header style while the pointer hovers it.
fn hovered_header_style(&self) -> TuiStyle {
self.primary_text_style().add_modifier(Modifier::BOLD)
Expand Down
110 changes: 110 additions & 0 deletions crates/warp_tui/src/warping_indicator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! The in-progress `⋮ Warping (Ns)` indicator row rendered between the
//! transcript and the input box while the selected conversation is in
//! progress — the TUI counterpart of the GUI's warping indicator.
//!
//! All animation state (spinner frame, shimmer phase, elapsed counter) is
//! derived from one [`AnimationClock`] carrying the exchange's elapsed time,
//! so it advances on cached-element repaints and survives element-tree
//! rebuilds.
//! The spinner and counter are [`TuiAnimated`] leaves and the shimmer label
//! self-schedules, so the row keeps requesting repaints while it is part of
//! the painted tree and stops as soon as the session view re-renders without
//! it.
use std::sync::LazyLock;
use std::time::Duration;

use warpui_core::elements::animation::{AnimationClock, Keyframe, KeyframeTimeline};
use warpui_core::elements::shimmer_math::ShimmerConfig;
use warpui_core::elements::tui::{
Modifier, TuiAnimated, TuiElement, TuiFlex, TuiShimmeringText, TuiText,
};
use warpui_core::AppContext;

use crate::tui_builder::TuiUiBuilder;

/// The spinner choreography from the Figma prototype: a 180° rotation right,
/// a 180° rotation back left, then a few fast full spins right, restarting.
///
/// Terminal cells can't rotate glyphs, so each 45° step maps to the nearest
/// three-dot orientation glyph (`⋮ ⋰ ⋯ ⋱`, repeating every 180°). The hold
/// durations are tuned by eye — the prototype's timings aren't
/// machine-readable.
static SPINNER_TIMELINE: LazyLock<KeyframeTimeline<&'static str>> = LazyLock::new(|| {
KeyframeTimeline::new([
// 180° right (clockwise), one 45° step per frame.
Keyframe::from_millis("⋮", 200),
Keyframe::from_millis("⋰", 200),
Keyframe::from_millis("⋯", 200),
Keyframe::from_millis("⋱", 200),
// 180° back left (counter-clockwise).
Keyframe::from_millis("⋮", 200),
Keyframe::from_millis("⋱", 200),
Keyframe::from_millis("⋯", 200),
Keyframe::from_millis("⋰", 200),
// Rest at vertical before the fast spins.
Keyframe::from_millis("⋮", 200),
// Fast spins right: one and a half turns (12 × 45° steps = 540°, three
// glyph cycles), ending back at vertical — the loop's restarting `⋮`
// doubles as the final step.
Keyframe::from_millis("⋰", 50),
Keyframe::from_millis("⋯", 50),
Keyframe::from_millis("⋱", 50),
Keyframe::from_millis("⋮", 50),
Keyframe::from_millis("⋰", 50),
Keyframe::from_millis("⋯", 50),
Keyframe::from_millis("⋱", 50),
Keyframe::from_millis("⋮", 50),
Keyframe::from_millis("⋰", 50),
Keyframe::from_millis("⋯", 50),
Keyframe::from_millis("⋱", 50),
])
});

/// Renders the `⋮ Warping (Ns)` row for an exchange that has been running for
/// `elapsed`.
pub(crate) fn render_warping_indicator(elapsed: Duration, app: &AppContext) -> Box<dyn TuiElement> {
let builder = TuiUiBuilder::from_app(app);
// One clock, already `elapsed` into the exchange, drives all three parts
// so they stay phase-locked; each repaint reads its current elapsed time.
let clock = AnimationClock::starting_at(elapsed);

// The spinner repaints at its timeline's shortest hold so the fast spins
// don't skip frames; repaint requests coalesce to the earliest deadline.
let spinner_style = builder.warping_spinner_style();
let spinner = TuiAnimated::new(Duration::from_millis(50), move || {
TuiText::new(*SPINNER_TIMELINE.value_at(clock.elapsed()))
.with_style(spinner_style)
.truncate()
.finish()
});

let label = TuiShimmeringText::new(
"Warping",
builder.warping_base_color(),
builder.warping_shimmer_color(),
ShimmerConfig::default(),
clock,
)
.with_modifier(Modifier::BOLD);

let counter_style = builder.muted_text_style();
let counter = TuiAnimated::new(Duration::from_secs(1), move || {
TuiText::new(format!("({}s)", clock.elapsed().as_secs()))
.with_style(counter_style)
.truncate()
.finish()
});

TuiFlex::row()
.child(spinner.finish())
.child(TuiText::new(" ").truncate().finish())
.child(label.finish())
.child(TuiText::new(" ").truncate().finish())
.child(counter.finish())
.finish()
}

#[cfg(test)]
#[path = "warping_indicator_tests.rs"]
mod tests;
Loading
Loading