Support rendering TUI conversations within an inset area (#58718)

## What changed

Split conversation rendering into preparation, painting, and post-draw steps. Use the supplied conversation bounds for transcript wrapping, composer layout, suggestion overlays, cursor placement, and the empty-state animation.

Preserve surrounding text and image placements during input-triggered redraws. Keep centered dialogs relative to the full viewport and restore neighboring content when they close.

## Testing

Extend regression tests and add a snapshot for conversations rendered at an offset, covering selection, link hit testing, cursor placement, scroll preservation, neighboring images, and dialog dismissal.

GitOrigin-RevId: 0dbca3f8d1064f2d58856c3bc77a4dab1c099cdd
This commit is contained in:
Felipe Coury
2026-11-01 15:50:25 +00:00
committed by copyberry
parent 231c8abd2f
commit 8c811e8d93
19 changed files with 661 additions and 249 deletions
+2 -6
View File
@@ -571,6 +571,7 @@ pub(crate) struct App {
native_history: native_history::NativeHistory,
turn_tips: turn_tips::TurnTips,
pub(crate) transcript_view: crate::transcript_view::TranscriptView,
conversation_frame: owned_transcript::ConversationFrame,
last_rendered_history_tail: Option<history_ui::RenderedHistoryTail>,
last_thread_usage_status_cell: Option<history_ui::ThreadUsageStatusHistory>,
pub(crate) pending_thread_usage_history_refresh: bool,
@@ -1210,12 +1211,7 @@ impl App {
self.discard_startup_modal_input(tui)?;
}
if self.chat_widget.ambient_pet_image_enabled() {
let ambient_pet_area = Rect::new(
/*x*/ 0,
/*y*/ 0,
screen_size.width,
screen_size.height,
);
let ambient_pet_area = self.conversation_area(screen_size.into());
if let Err(err) = tui.draw_ambient_pet_image(
self.chat_widget
.ambient_pet_draw(ambient_pet_area, rendered_area.bottom()),
+1 -2
View File
@@ -479,8 +479,7 @@ impl App {
self.chat_widget.maybe_send_next_queued_input();
}
AppEvent::SelectTranscriptCopy { guard } => {
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
self.refresh_owned_transcript(tui)?;
if !self.transcript_view.begin_copy_mode(&self.transcript_cells, Some(guard)) {
self.chat_widget.add_info_message("Nothing to copy".into(), /*hint*/ None);
}
+1 -2
View File
@@ -27,8 +27,7 @@ impl App {
let refresh = matches!(event, TuiEvent::Mouse(mouse)
if self.chat_widget.rendered_selection.borrow().needs_refresh(*mouse));
if refresh {
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
self.refresh_owned_transcript(tui)?;
}
let action = {
let mut selection = self.chat_widget.rendered_selection.borrow_mut();
+374 -231
View File
@@ -1,4 +1,6 @@
//! Compose the owned transcript above the composer and route their selection gestures.
//! Painting records the screen regions used by selection and pointer input.
//! Input that depends on those regions refreshes the layout before using them.
//! Reserve a cleared row below activity and previews, immediately above the composer.
//! Slash suggestions overlay already-painted rows so opening or closing them leaves transcript
//! geometry unchanged. Turn tips remain outside transcript history; their painted rows are selectable.
@@ -6,6 +8,10 @@
use super::turn_tips::TipSurface;
use super::*;
use crate::bottom_pane::CommandPopupPlacement;
use crate::bottom_pane::ComposerRenderOptions;
use crate::custom_terminal::Frame;
use crate::empty_state_animation::ComposerState;
use crate::history_cell::HistoryRenderMode;
use crate::keymap::KeymapContext;
use crate::keymap::bindings_for_action;
@@ -13,9 +19,81 @@ use crate::keymap::configured_binding_for_action;
use crate::keymap::keymap_action_ids;
use crate::motion::MotionMode;
use crate::pager_overlay::TranscriptHistoryState;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::transcript_image::ImagePlacement;
use crate::transcript_view::TranscriptView;
use crate::transcript_view::ViewAction;
use crate::turn_tip::TurnTip;
use ratatui::widgets::Widget;
/// Remember the last conversation bounds and neighboring content for input-triggered redraws.
#[derive(Default)]
pub(super) struct ConversationFrame {
inset: Option<Rect>,
/// A dialog can obscure neighboring content in the terminal's completed buffer.
behind_dialog: Option<ratatui::buffer::Buffer>,
caller_images: Vec<ImagePlacement>,
}
pub(super) struct PreparedConversation {
area: Rect,
transcript_width: u16,
composer: Option<ComposerState>,
composer_hint: Option<HyperlinkLine>,
latest_navigation: &'static str,
motion: MotionMode,
now: Instant,
turn_tip: Option<(TipSurface, TurnTip)>,
}
/// The composer bounds and follow-up work from a successful draw.
/// Acknowledge a tip only if it was visible in the frame that was presented.
#[derive(Default)]
pub(super) struct PaintedConversation {
image_range: std::ops::Range<usize>,
bottom_area: Rect,
shown_tip: Option<TipSurface>,
needs_redraw: bool,
/// Delays requested by copy feedback, the empty-state animation, and the transcript.
delayed_frames: [Option<Duration>; 3],
}
impl PreparedConversation {
/// Paint the conversation foreground before a centered dialog takes over the cursor.
fn paint_foreground(
&self,
frame: &mut Frame<'_>,
chat_widget: &ChatWidget,
view: &TranscriptView,
bottom: &dyn Renderable,
bottom_area: Rect,
) {
{
let mut selection = chat_widget.rendered_selection.borrow_mut();
selection.register_layout(chat_widget.take_warning_body());
if let Some((_, tip)) = &self.turn_tip
&& let Some(area) = tip.rendered.get()
{
selection.register(area, /*invalidate*/ false);
}
selection.render(frame.buffer);
}
chat_widget.note_rendered_width(self.area.width);
if let Some(position) = bottom.cursor_pos(bottom_area)
&& self.area.contains(position.into())
{
frame.set_cursor_style(bottom.cursor_style(bottom_area));
frame.set_cursor_position(position);
}
if chat_widget.no_modal_or_popup_active() {
frame.image_placements.extend(view.image_placements());
if let Some(previews) = &chat_widget.composer_images {
frame.image_placements.extend(previews.placements.take());
}
}
}
}
impl App {
/// Copy draft selections before key shortcuts, or after mouse layout has been refreshed.
pub(super) fn handle_composer_copy_event(
@@ -115,6 +193,49 @@ impl App {
tui: &mut tui::Tui,
screen_size: Size,
) -> Result<Rect> {
let frames = tui.frame_requester();
let prepared =
self.prepare_conversation(screen_size.into(), self.conversation_frame.inset, &frames);
// An input refresh opens a fresh terminal buffer. Carry the outer owner's
// completed cells through it when only the conversation is being redrawn.
let backdrop = self.conversation_frame.inset.map(|_| {
self.conversation_frame
.behind_dialog
.take()
.unwrap_or_else(|| tui.terminal.previous_buffer().clone())
});
let mut painted = PaintedConversation::default();
tui.draw(screen_size.height, |frame| {
if let Some(backdrop) = backdrop {
let viewport = frame.area();
frame.image_placements.extend(
self.conversation_frame
.caller_images
.drain(..)
.filter(|placement| {
viewport.intersection(placement.area) == placement.area
}),
);
for (pos, cell) in backdrop.area.positions().zip(backdrop.content) {
if viewport.contains(pos) && !prepared.area.contains(pos) {
frame.buffer[pos] = cell;
}
}
}
painted = self.paint_conversation(frame, &prepared);
self.paint_centered_dialog(frame);
})?;
self.finish_conversation(tui, painted)
}
pub(super) fn prepare_conversation(
&mut self,
viewport: Rect,
conversation_inset: Option<Rect>,
frames: &tui::FrameRequester,
) -> PreparedConversation {
self.conversation_frame.inset = conversation_inset;
let area = self.conversation_area(viewport);
self.chat_widget.sync_warnings(&self.transcript_cells);
let motion = MotionMode::from_animations_enabled(
self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer,
@@ -125,8 +246,11 @@ impl App {
} else {
"esc latest"
};
self.sync_owned_transcript(screen_size.width);
let transcript_width = self.chat_widget.history_wrap_width(screen_size.width);
self.sync_owned_transcript(area.width);
let transcript_width = self
.chat_widget
.history_wrap_width(area.width)
.min(area.width);
let composer_hint = if self
.chat_widget
.rendered_selection
@@ -143,256 +267,277 @@ impl App {
self.composer_hint(transcript_width)
};
let now = Instant::now();
let turn_tip = self.turn_tip(transcript_width, now, &tui.frame_requester());
let working_tip = turn_tip
.as_ref()
.filter(|(surface, _)| *surface == TipSurface::Working)
.map(|(_, tip)| tip);
let completion_tip = turn_tip
.as_ref()
.filter(|(surface, _)| *surface == TipSurface::Completion)
.map(|(_, tip)| tip);
let mut composer_gap = (!self.chat_widget.has_active_view()
&& !self.chat_widget.is_external_writer_view())
.then(crate::bottom_pane::ComposerGap::default);
let mut prompt_footer =
self.prompt_navigation_footer(screen_size.width.saturating_sub(/*rhs*/ 2));
let chat_widget = &self.chat_widget;
let turn_tip = self.turn_tip(transcript_width, now, frames);
let view = &mut self.transcript_view;
let active_key = chat_widget.active_cell_transcript_key();
view.sync_history_tail(&self.transcript_cells);
view.prepare_width(transcript_width);
if view.advance_search(&self.transcript_cells) {
tui.frame_requester().schedule_frame();
frames.schedule_frame();
}
let footer_area = crate::bottom_pane::inset_footer_hint_area(Rect::new(
/*x*/ 0,
/*y*/ 0,
screen_size.width,
/*height*/ 1,
));
let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation);
// Cap the whole composer (including padding and hints) at two-thirds of the screen,
// but allow at least 8 rows on small screens, without exceeding the screen itself.
let max_composer_height = ((u32::from(screen_size.height) * 2 / 3) as u16)
PreparedConversation {
area,
transcript_width,
composer,
composer_hint,
latest_navigation,
motion,
now,
turn_tip,
}
}
pub(super) fn paint_conversation(
&mut self,
frame: &mut Frame<'_>,
prepared: &PreparedConversation,
) -> PaintedConversation {
let PreparedConversation {
area,
transcript_width,
composer,
latest_navigation,
motion,
now,
..
} = *prepared;
let (working_tip, completion_tip) = match prepared.turn_tip.as_ref() {
Some((TipSurface::Working, tip)) => (Some(tip), None),
Some((TipSurface::Completion, tip)) => (None, Some(tip)),
None => (None, None),
};
let mut composer_gap = (!self.chat_widget.has_active_view()
&& !self.chat_widget.is_external_writer_view())
.then(crate::bottom_pane::ComposerGap::default);
let prompt_footer = self.prompt_navigation_footer(area.width.saturating_sub(/*rhs*/ 2));
let chat_widget = &self.chat_widget;
let view = &mut self.transcript_view;
let active_key = chat_widget.active_cell_transcript_key();
let footer_width = crate::bottom_pane::inset_footer_hint_area(area).width;
// Measure the composer to find the space available for transcript rows.
// Painting those rows tells us whether newer activity is still hidden,
// which can change the footer hint and the composer's measured height.
let footer = view.footer_with_navigation(footer_width, motion, latest_navigation);
// Allow up to two-thirds of the pane, or eight rows on short panes when they fit.
let max_composer_height = ((u32::from(area.height) * 2 / 3) as u16)
.max(8)
.min(screen_size.height);
let bottom =
chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
max_height: Some(max_composer_height),
footer: prompt_footer.as_ref().or(footer.as_ref()),
command_popup_placement: if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
composer_gap: composer_gap.as_ref(),
working_tip,
..Default::default()
});
let dashboard_visible = chat_widget
.min(area.height);
let options = |interacting| ComposerRenderOptions {
max_height: Some(max_composer_height),
overlay_top: area.top(),
command_popup_placement: if interacting {
CommandPopupPlacement::Hidden
} else {
CommandPopupPlacement::Overlay
},
working_tip,
..Default::default()
};
let bottom = chat_widget.bottom_pane_renderable(ComposerRenderOptions {
footer: prompt_footer.as_ref().or(footer.as_ref()),
composer_gap: composer_gap.as_ref(),
..options(view.has_active_interaction())
});
let agents_overview_present = chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some();
let bottom_height = if dashboard_visible {
screen_size.height
} else {
bottom
.desired_height(screen_size.width)
.min(screen_size.height)
let measure_bottom = |bottom: &dyn Renderable| {
if agents_overview_present {
area.height
} else {
bottom.desired_height(area.width).min(area.height)
}
};
let bottom_height = measure_bottom(&bottom);
drop(bottom);
let available = screen_size.height.saturating_sub(bottom_height);
let rows_above_composer = area.height - bottom_height;
let mut bottom_area = Rect::new(
/*x*/ 0,
screen_size.height.saturating_sub(bottom_height),
screen_size.width,
area.x,
area.bottom().saturating_sub(bottom_height),
area.width,
bottom_height,
);
let mut rendered_cursor = None;
let mut footer_height_changed = false;
let mut feedback_tick = None;
let mut blossom_tick = None;
let mut transcript_bottom = available.saturating_sub(u16::from(composer_gap.is_none()));
tui.draw(screen_size.height, |frame| {
chat_widget.rendered_selection.borrow_mut().areas.clear();
ratatui::widgets::Clear.render(
Rect::new(/*x*/ 0, /*y*/ 0, screen_size.width, available),
frame.buffer,
);
let mut completion_tip_area = view.render_with_turn_tip_space(
Rect::new(
/*x*/ 0,
/*y*/ 0,
transcript_width,
transcript_bottom,
),
let mut transcript_area = Rect::new(
area.x,
area.y,
transcript_width,
rows_above_composer.saturating_sub(u16::from(composer_gap.is_none())),
);
chat_widget.rendered_selection.borrow_mut().areas.clear();
ratatui::widgets::Clear.render(area, frame.buffer);
let mut completion_tip_area = view.render_with_turn_tip_space(
transcript_area,
frame.buffer,
&self.transcript_cells,
completion_tip,
);
if let Some(gap) = composer_gap.as_mut() {
gap.needs_separator = rows_above_composer > 1
// Keep the tip under the pointer when selection hints or copy feedback appear.
&& prepared.turn_tip.is_none()
&& chat_widget.no_modal_or_popup_active()
&& view.composer_gap_has_content(transcript_width, prepared.composer_hint.as_ref(), now);
}
// Refresh the hint using the rows just painted.
let mut footer = prompt_footer
.or_else(|| view.footer_with_navigation(footer_width, motion, latest_navigation));
if let Some(footer) = footer.as_mut().filter(|footer| footer.is_interactive)
&& let Some(items) = self
.key_chord_matcher
.pending_hint_items(&self.keymap.chords, footer_width)
&& let Some(progress) = footer.text.lines.last_mut()
{
*progress = crate::bottom_pane::footer_hint_items_line(&items);
}
let bottom = chat_widget.bottom_pane_renderable(ComposerRenderOptions {
footer: footer.as_ref(),
composer_gap: composer_gap.as_ref(),
..options(view.has_active_interaction())
});
let updated_bottom_height = measure_bottom(&bottom);
let mut needs_redraw = updated_bottom_height != bottom_height;
if needs_redraw && composer_gap.as_ref().is_some_and(|gap| gap.needs_separator) {
bottom_area.height = updated_bottom_height;
bottom_area.y = area.bottom().saturating_sub(bottom_area.height);
// If the separator changes the composer height, repaint the transcript now
// so its follow control lines up with the gap drawn below. Other height
// changes request another frame after this draw.
ratatui::widgets::Clear.render(bottom_area, frame.buffer);
transcript_area.height = bottom_area.y - area.y;
completion_tip_area = view.render_with_turn_tip_space(
transcript_area,
frame.buffer,
&self.transcript_cells,
completion_tip,
);
if let Some(gap) = composer_gap.as_mut() {
gap.needs_separator = available > 1
// Keep the tip under the pointer when selection hints or copy feedback appear.
&& turn_tip.is_none()
&& chat_widget.no_modal_or_popup_active()
&& view.composer_gap_has_content(transcript_width, composer_hint.as_ref(), now);
}
// Rendering resolves whether new activity is still hidden. Paint that result in
// this frame so a revision change cannot flash a stale activity hint.
let mut footer =
view.footer_with_navigation(footer_area.width, motion, latest_navigation);
if let Some(footer) = prompt_footer
.as_mut()
.or(footer.as_mut())
.filter(|footer| footer.is_interactive)
&& let Some(items) = self
.key_chord_matcher
.pending_hint_items(&self.keymap.chords, footer_area.width)
&& let Some(progress) = footer.text.lines.last_mut()
{
*progress = crate::bottom_pane::footer_hint_items_line(&items);
}
let bottom =
chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
max_height: Some(max_composer_height),
footer: prompt_footer.as_ref().or(footer.as_ref()),
command_popup_placement: if view.has_active_interaction() {
crate::bottom_pane::CommandPopupPlacement::Hidden
} else {
crate::bottom_pane::CommandPopupPlacement::Overlay
},
composer_gap: composer_gap.as_ref(),
working_tip,
..Default::default()
});
footer_height_changed = !dashboard_visible
&& bottom
.desired_height(screen_size.width)
.min(screen_size.height)
!= bottom_height;
if footer_height_changed && composer_gap.as_ref().is_some_and(|gap| gap.needs_separator)
{
bottom_area.height = bottom
.desired_height(screen_size.width)
.min(screen_size.height);
bottom_area.y = screen_size.height.saturating_sub(bottom_area.height);
// Resolve controls with the compact viewport first, then make room for
// their separator. Resizing must not preserve a stale return control.
ratatui::widgets::Clear.render(bottom_area, frame.buffer);
transcript_bottom = bottom_area.y;
completion_tip_area = view.render_with_turn_tip_space(
Rect::new(
/*x*/ 0,
/*y*/ 0,
transcript_width,
transcript_bottom,
),
frame.buffer,
&self.transcript_cells,
completion_tip,
);
footer_height_changed = false;
}
blossom_tick = chat_widget
.empty_state_animation
.borrow_mut()
.render_first_screen(
Rect {
width: screen_size.width,
..view.remaining_area()
},
frame.buffer,
composer,
MotionMode::from_animations_enabled(self.local_settings.tui.animations),
);
bottom.render(bottom_area, frame.buffer);
if let (Some(tip), Some(area)) = (completion_tip, completion_tip_area) {
tip.render(area, frame.buffer);
}
let follow_area = if let Some(gap) = composer_gap.as_ref() {
Some(Rect {
width: transcript_width,
..gap.area.get()
})
} else {
(available > 0).then(|| {
Rect::new(
/*x*/ 0,
available - 1,
transcript_width,
/*height*/ 1,
)
})
}
.filter(|_| chat_widget.no_modal_or_popup_active());
feedback_tick =
view.render_composer_gap(follow_area, composer_hint.as_ref(), frame.buffer, now);
chat_widget
.rendered_selection
.borrow_mut()
.register_layout(chat_widget.take_warning_body());
if let Some((_, tip)) = &turn_tip
&& let Some(area) = tip.rendered.get()
{
chat_widget
.rendered_selection
.borrow_mut()
.register(area, /*invalidate*/ false);
}
chat_widget
.rendered_selection
.borrow_mut()
.render(frame.buffer);
chat_widget.note_rendered_width(screen_size.width);
let dialog = chat_widget.centered_dialog();
let (foreground, foreground_area): (&dyn Renderable, Rect) =
if let Some(dialog) = &dialog {
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
screen_size.width,
screen_size.height,
);
dialog.render(area, frame.buffer);
(dialog, area)
} else {
(&bottom, bottom_area)
};
rendered_cursor = foreground.cursor_pos(foreground_area);
if let Some(position) = rendered_cursor {
frame.set_cursor_style(foreground.cursor_style(foreground_area));
frame.set_cursor_position(position);
}
if chat_widget.no_modal_or_popup_active() {
frame.image_placements = view.image_placements();
if let Some(previews) = &chat_widget.composer_images {
frame.image_placements.extend(previews.placements.take());
}
}
})?;
if let Some((surface, tip)) = &turn_tip
&& tip.rendered.get().is_some()
{
self.turn_tips.acknowledge(*surface);
needs_redraw = false;
}
if footer_height_changed {
tui.frame_requester().schedule_frame();
let blossom_tick = chat_widget
.empty_state_animation
.borrow_mut()
.render_first_screen(
area,
Rect {
width: area.width,
..view.remaining_area()
},
frame.buffer,
composer,
MotionMode::from_animations_enabled(self.local_settings.tui.animations),
);
bottom.render(bottom_area, frame.buffer);
if let (Some(tip), Some(area)) = (completion_tip, completion_tip_area) {
tip.render(area, frame.buffer);
}
if let Some(delay) = feedback_tick {
tui.frame_requester().schedule_frame_in(delay);
}
if let Some(delay) = blossom_tick {
tui.frame_requester().schedule_frame_in(delay);
let follow_area = if let Some(gap) = composer_gap.as_ref() {
Some(Rect {
width: transcript_width,
..gap.area.get()
})
} else {
(bottom_area.y > area.y).then(|| {
Rect::new(
area.x,
bottom_area.y - 1,
transcript_width,
/*height*/ 1,
)
})
}
.filter(|_| chat_widget.no_modal_or_popup_active());
let feedback_tick = view.render_composer_gap(
follow_area,
prepared.composer_hint.as_ref(),
frame.buffer,
now,
);
let first_image = frame.image_placements.len();
prepared.paint_foreground(frame, chat_widget, view, &bottom, bottom_area);
let animating =
view.is_following() && active_key.is_some_and(|key| key.animation_tick.is_some());
let loading = view.is_loading_history() && motion == MotionMode::Animated;
if animating || loading {
tui.frame_requester()
.schedule_frame_in(Duration::from_millis(/*millis*/ 50));
PaintedConversation {
image_range: first_image..frame.image_placements.len(),
bottom_area,
shown_tip: prepared
.turn_tip
.as_ref()
.and_then(|(surface, tip)| tip.rendered.get().map(|_| *surface)),
needs_redraw,
delayed_frames: [
feedback_tick,
blossom_tick,
(animating || loading).then_some(Duration::from_millis(50)),
],
}
}
/// Acknowledge only a presented frame. Keep neighboring images for the next input refresh.
pub(super) fn finish_conversation(
&mut self,
tui: &mut tui::Tui,
painted: PaintedConversation,
) -> Result<Rect> {
if self.conversation_frame.behind_dialog.is_none() {
self.conversation_frame.caller_images.clear();
if self.conversation_frame.inset.is_some() {
self.conversation_frame.caller_images.extend(
tui.retained_images
.iter()
.enumerate()
.filter(|(index, _)| !painted.image_range.contains(index))
.map(|(_, placement)| placement.registration()),
);
}
}
if let Some(surface) = painted.shown_tip {
self.turn_tips.acknowledge(surface);
}
let frames = tui.frame_requester();
if painted.needs_redraw {
frames.schedule_frame();
}
for delay in painted.delayed_frames.into_iter().flatten() {
frames.schedule_frame_in(delay);
}
self.refresh_link_hover(tui)?;
Ok(bottom_area)
Ok(painted.bottom_area)
}
pub(super) fn paint_centered_dialog(&mut self, frame: &mut Frame<'_>) {
self.conversation_frame.behind_dialog = None;
let Some(dialog) = self.chat_widget.centered_dialog() else {
return;
};
let area = frame.area();
if self.conversation_frame.inset.is_some() {
self.conversation_frame.behind_dialog = Some(frame.buffer.clone());
// The conversation omits its own images while a dialog is open. Keep the
// caller's registrations so dismissal can restore graphics behind the dialog.
self.conversation_frame.caller_images = std::mem::take(&mut frame.image_placements);
let covered = dialog.area(area);
frame.image_placements.extend(
self.conversation_frame
.caller_images
.iter()
.filter(|placement| !covered.intersects(placement.area))
.map(ImagePlacement::registration),
);
}
dialog.render(area, frame.buffer);
frame.cursor_position = dialog.cursor_pos(area).map(Into::into);
frame.set_cursor_style(dialog.cursor_style(area));
}
pub(super) fn conversation_area(&self, viewport: Rect) -> Rect {
self.conversation_frame
.inset
.map_or(viewport, |area| area.intersection(viewport))
}
pub(super) fn refresh_owned_transcript(&mut self, tui: &mut tui::Tui) -> Result<()> {
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
Ok(())
}
/// Keep modal input ownership while allowing selection, copying, and disclosure in the visible transcript.
@@ -458,8 +603,7 @@ impl App {
if let TuiEvent::Mouse(mouse) = event {
let composer_ready = self.chat_widget.prepare_composer_mouse(*mouse);
if mouse.kind != crossterm::event::MouseEventKind::Moved {
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
self.refresh_owned_transcript(tui)?;
}
if self.chat_widget.no_modal_or_popup_active()
&& self
@@ -529,8 +673,7 @@ impl App {
_ => true,
})
{
let size = tui.prepare_draw_size()?;
self.render_owned_transcript(tui, size)?;
self.refresh_owned_transcript(tui)?;
}
if matches!(event, TuiEvent::Key(_))
|| matches!(event, TuiEvent::Mouse(mouse) if matches!(mouse.kind, crossterm::event::MouseEventKind::Down(_)))
@@ -62,6 +62,35 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R
tui.screen_size_for_event(&TuiEvent::Resize(size))?;
app.render_owned_transcript(&mut tui, size)?;
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let scrolled = super::tests::buffer_text(buffer);
let first_row = scrolled.lines().next().expect("visible transcript row");
assert!(first_row.contains("transcript row"));
let area = Rect::new(
/*x*/ 4, /*y*/ 1, /*width*/ 30, /*height*/ 10,
);
let frames = tui.frame_requester();
let mut painted = Default::default();
tui.draw(size.height, |frame| {
let prepared = app.prepare_conversation(frame.area(), Some(area), &frames);
painted = app.paint_conversation(frame, &prepared);
})?;
app.finish_conversation(&mut tui, painted)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let resized = super::tests::buffer_text(buffer);
assert_eq!(
resized.lines().nth(usize::from(area.y)).map(str::trim),
Some(first_row.trim())
);
assert!(resized.contains("draft stays here"));
tui.screen_size_for_event(&TuiEvent::Resize(size))?;
let mut painted = Default::default();
tui.draw(size.height, |frame| {
let prepared =
app.prepare_conversation(frame.area(), /*conversation_inset*/ None, &frames);
painted = app.paint_conversation(frame, &prepared);
})?;
app.finish_conversation(&mut tui, painted)?;
for height in [24, 9, 8, 12] {
let resized = Size::new(width, height);
tui.screen_size_for_event(&TuiEvent::Resize(resized))?;
@@ -644,6 +644,40 @@ async fn enter_preserves_search_backtrack_and_modal_ownership_while_scrolled() -
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
assert!(!app.transcript_view.is_following());
let outer = tui.terminal.size()?;
let area = Rect::new(
/*x*/ 48, /*y*/ 2, /*width*/ 26, /*height*/ 14,
);
let frames = tui.frame_requester();
let mut painted = Default::default();
tui.draw(outer.height, |frame| {
for cell in &mut frame.buffer.content {
cell.set_char('.');
}
let prepared = app.prepare_conversation(frame.area(), Some(area), &frames);
painted = app.paint_conversation(frame, &prepared);
app.paint_centered_dialog(frame);
assert!(frame.cursor_position.is_none());
})?;
app.finish_conversation(&mut tui, painted)?;
let dialog = screen(&tui);
let title = dialog
.lines()
.find(|line| line.contains("Enable subagents?"))
.expect("confirmation title");
assert!(title.find("Enable subagents?").unwrap() < usize::from(area.x));
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
.await?;
assert!(!app.chat_widget.has_active_modal());
assert!(!app.transcript_view.is_following());
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
.await?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
for pos in buffer.area.positions().filter(|pos| !area.contains(*pos)) {
assert_eq!(buffer[pos].symbol(), ".");
}
tui.set_owned_screen(/*owned*/ false)?;
server.shutdown().await?;
Ok(())
@@ -536,6 +536,157 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
assert!(rendered.contains("First prompt"));
assert!(rendered.contains("Second prompt"));
assert!(rendered.contains("preserved draft"));
app.transcript_cells
.push(Arc::new(history_cell::AgentMarkdownCell::new(
"For the complete instructions see the [manual](https://example.com/docs).".into(),
app.config.cwd.as_path(),
)));
let area = Rect::new(
/*x*/ 5, /*y*/ 2, /*width*/ 26, /*height*/ 14,
);
let outer = Size::new(/*width*/ 36, /*height*/ 18);
tui.screen_size_for_event(&TuiEvent::Resize(outer))?;
let dir = tempfile::tempdir()?;
let path = dir.path().join("preview.png");
image::RgbaImage::from_pixel(8, 16, image::Rgba([80, 30, 20, 255])).save(&path)?;
let protocol = crate::pets::ImageProtocol::KittyLocalFile;
let image = Arc::new(
crate::transcript_image::TranscriptImage::load(&path, area.width, protocol).unwrap(),
);
let neighbor = Rect::new(/*x*/ 4, /*y*/ 9, image.columns, image.rows);
let presented = |tui: &tui::Tui| {
tui.retained_images
.iter()
.map(|placement| {
assert!(placement.file.as_ref().unwrap().path().exists());
placement.area
})
.collect::<Vec<_>>()
};
let frames = tui.frame_requester();
let mut painted = Default::default();
tui.draw(outer.height, |frame| {
for cell in &mut frame.buffer.content {
cell.set_char('·');
}
let prepared = app.prepare_conversation(frame.area(), Some(area), &frames);
painted = app.paint_conversation(frame, &prepared);
frame
.image_placements
.push(crate::transcript_image::ImagePlacement {
image,
area: neighbor,
first_row: 0,
file: None,
});
})?;
app.finish_conversation(&mut tui, painted)?;
assert_eq!(presented(&tui), [neighbor]);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let screen = crate::terminal_hyperlinks::strip_osc8(&buffer_text(buffer));
let caret = tui.terminal.last_known_cursor_pos;
assert!(area.contains(caret));
assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "t");
let (column, row) = screen
.lines()
.enumerate()
.find_map(|(row, line)| {
let prefix = line.split_once("manual")?.0;
Some((prefix.chars().count() as u16, row as u16))
})
.expect("visible wrapped link");
assert_eq!(
app.transcript_view.link_at(column, row).as_deref(),
Some("https://example.com/docs")
);
assert_eq!(app.transcript_view.link_at(area.x - 1, row), None);
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app.local_settings.tui.copy_on_select = CopyOnSelect::Never;
for (kind, x) in [
(Down(Left), column),
(Drag(Left), column + 6),
(Up(Left), column + 6),
] {
app.handle_tui_event(
&mut tui,
&mut server,
TuiEvent::Mouse(MouseEvent {
kind,
column: x,
row,
modifiers: KeyModifiers::NONE,
}),
)
.await?;
assert_eq!(presented(&tui), [neighbor], "after {kind:?}");
}
assert_eq!(
app.transcript_view
.selected_text(&app.transcript_cells)
.as_deref(),
Some("manual")
);
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
.await?;
assert_eq!(presented(&tui), [neighbor]);
assert_eq!(
app.transcript_view.link_at(column, row).as_deref(),
Some("https://example.com/docs")
);
let guard = Arc::new(crate::copy_input_guard::CopyInputGuard(
app.app_event_tx.clone(),
));
app.handle_event(
&mut tui,
&mut server,
AppEvent::SelectTranscriptCopy { guard },
)
.await?;
assert_eq!(presented(&tui), [neighbor]);
assert_eq!(
app.transcript_view.link_at(column, row).as_deref(),
Some("https://example.com/docs")
);
assert_eq!(tui.terminal.last_known_cursor_pos, caret);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
for pos in Rect::from(outer)
.positions()
.filter(|pos| !area.contains(*pos))
{
assert_eq!(buffer[pos].symbol(), "·");
}
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
.await?;
app.chat_widget
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(protocol));
app.transcript_cells
.push(Arc::new(history_cell::new_image_generation_call(
"image-call".to_string(),
"completed",
Some("Preview".to_string()),
Some(AbsolutePathBuf::try_from(path)?),
)));
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
.await?;
let images = presented(&tui);
assert_eq!(images.len(), 2);
assert_eq!(images[0], neighbor);
assert_eq!(area.intersection(images[1]), images[1]);
app.chat_widget.open_feature_enable_prompt(Feature::Collab);
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
.await?;
assert!(presented(&tui).is_empty());
app.transcript_cells.pop();
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
.await?;
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
.await?;
assert_eq!(presented(&tui), [neighbor]);
server.shutdown().await?;
insta::assert_snapshot!(
"conversation_in_offset_rect",
normalize_snapshot_paths(screen)
);
assert!(app.handle_owned_backtrack_event(
&mut tui,
&TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
@@ -0,0 +1,22 @@
---
source: tui/src/app/owned_transcript_tests.rs
expression: normalize_snapshot_paths(screen)
---
····································
····································
····· ·····
·····› Second prompt ·····
····· ·····
····· ·····
·····• For the complete ·····
····· instructions see the ·····
····· manual (https:// ·····
····· example.com/docs). ·····
····· ·····
····· ·····
·····› preserved draft ·····
····· ·····
····· gpt-test default · /tmp…·····
····· ·····
····································
····································
+1
View File
@@ -999,6 +999,7 @@ See the Codex keymap documentation for supported actions and examples."
native_history: Default::default(),
turn_tips: Default::default(),
transcript_view: Default::default(),
conversation_frame: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
+1
View File
@@ -48,6 +48,7 @@ pub(crate) async fn make_test_app() -> Box<App> {
native_history: Default::default(),
turn_tips: Default::default(),
transcript_view: Default::default(),
conversation_frame: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
+2
View File
@@ -6513,6 +6513,7 @@ async fn make_test_app() -> Box<App> {
native_history: Default::default(),
turn_tips: Default::default(),
transcript_view: Default::default(),
conversation_frame: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
@@ -6639,6 +6640,7 @@ pub(super) async fn make_test_app_with_channels() -> (
native_history: Default::default(),
turn_tips: Default::default(),
transcript_view: Default::default(),
conversation_frame: Default::default(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
+7 -1
View File
@@ -248,7 +248,13 @@ async fn turn_tip_placements_and_completion_barrier() -> Result<()> {
}
}
assert_eq!(saw_barrier, !working);
app.render_owned_transcript(&mut tui, size)?;
let frames = tui.frame_requester();
let mut painted = Default::default();
tui.draw(height, |frame| {
let prepared = app.prepare_conversation(frame.area(), Some(size.into()), &frames);
painted = app.paint_conversation(frame, &prepared);
})?;
app.finish_conversation(&mut tui, painted)?;
let screen = crate::chatwidget::tests::helpers::normalize_completion_timestamps(
app.transcript_cells.last().unwrap().as_ref(),
buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
@@ -4646,11 +4646,14 @@ impl ChatComposer {
&& options.command_popup_placement != CommandPopupPlacement::Hidden
{
let popup_rect = if options.command_popup_placement == CommandPopupPlacement::Overlay {
let rows_above_composer = composer_rect
.y
.saturating_sub(options.overlay_top.max(buf.area.y));
let height = self
.popups
.active
.required_height(composer_rect.width, /*footer_total_height*/ 0)
.min(composer_rect.y.saturating_sub(buf.area.y));
.min(rows_above_composer);
// Command and unified mention menus can render in a single row.
let height = if matches!(
self.popups.active,
@@ -82,6 +82,8 @@ pub(crate) struct ComposerRenderOptions<'a> {
pub(crate) textarea_right_reserve: u16,
/// Keep configured status below the composer while hints occupy the final row.
pub(crate) separate_status_line: bool,
/// Topmost frame row available for suggestions overlaying the transcript.
pub(crate) overlay_top: u16,
pub(crate) command_popup_placement: CommandPopupPlacement,
pub(crate) footer: Option<&'a TranscriptFooter>,
}
+11 -4
View File
@@ -72,9 +72,15 @@ impl Renderable for ViewStack<'_> {
/// A dialog placed within the supplied viewport, without taking over its backdrop.
pub(crate) struct CenteredView<'a>(pub(crate) &'a dyn BottomPaneView);
impl CenteredView<'_> {
pub(crate) fn area(&self, viewport: Rect) -> Rect {
dialog_area(self.0, viewport)
}
}
impl Renderable for CenteredView<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let dialog = dialog_area(self.0, area);
let dialog = self.area(area);
Clear.render(dialog, buf);
// A dialog can cover cached blank cells whose color the terminal did not retain.
// Paint its whole surface; let wide text rendering still reset continuation cells.
@@ -87,15 +93,16 @@ impl Renderable for CenteredView<'_> {
}
fn desired_height(&self, width: u16) -> u16 {
dialog_area(self.0, Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)).height
self.area(Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX))
.height
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.0.cursor_pos(dialog_area(self.0, area))
self.0.cursor_pos(self.area(area))
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.0.cursor_style(dialog_area(self.0, area))
self.0.cursor_style(self.area(area))
}
}
+1 -1
View File
@@ -192,6 +192,7 @@ impl EmptyStateAnimation {
/// on clear, without restarting or scheduling animation.
pub(crate) fn render_first_screen(
&mut self,
screen: Rect,
available: Rect,
buffer: &mut Buffer,
composer: Option<ComposerState>,
@@ -199,7 +200,6 @@ impl EmptyStateAnimation {
) -> Option<Duration> {
const STAGE_ROWS: u16 = 21;
const MIN_STAGE_ROWS: u16 = 14;
let screen = buffer.area;
let width = screen.width.saturating_sub(/*rhs*/ 4).min(MAX_COLUMNS);
let height = width * STAGE_ROWS / MAX_COLUMNS;
if motion == MotionMode::Reduced
@@ -22,6 +22,7 @@ fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() {
);
assert_eq!(
animation.render_first_screen(
screen,
free,
&mut buffer,
Some(ComposerState::Empty),
@@ -39,6 +40,7 @@ fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() {
// Content which reaches the center must win; it must not move the stage down.
let free = Rect::new(screen.x, /*y*/ 20, screen.width, /*height*/ 24);
animation.render_first_screen(
screen,
free,
&mut buffer,
Some(ComposerState::Empty),
@@ -50,6 +52,7 @@ fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() {
let screen = Rect::new(/*x*/ 7, /*y*/ 3, width, height);
let mut buffer = Buffer::empty(screen);
animation.render_first_screen(
screen,
screen,
&mut buffer,
Some(ComposerState::Empty),
@@ -75,7 +78,7 @@ fn first_screen_single_click_replays_the_visible_blossom() {
};
let draw = |animation: &mut EmptyStateAnimation, buffer: &mut Buffer, composer| {
buffer.reset();
animation.render_first_screen(screen, buffer, composer, MotionMode::Animated)
animation.render_first_screen(screen, screen, buffer, composer, MotionMode::Animated)
};
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
@@ -193,6 +196,7 @@ fn first_screen_single_click_replays_the_visible_blossom() {
// Disabling motion removes both the logo and its click target.
buffer.reset();
animation.render_first_screen(
screen,
screen,
&mut buffer,
Some(ComposerState::Empty),
+1
View File
@@ -69,6 +69,7 @@ impl Renderable for OwnedStartupLayout<'_> {
.flatten();
self.next_frame
.set(self.pump.blossom.borrow_mut().render_first_screen(
area,
Rect {
y: header.bottom(),
height: bottom.y.saturating_sub(header.bottom()),
+12
View File
@@ -33,6 +33,18 @@ pub(crate) struct ImagePlacement {
pub(crate) file: Option<tempfile::NamedTempFile>,
}
impl ImagePlacement {
/// Register the same pixels for another frame. The terminal owns the transport file.
pub(crate) fn registration(&self) -> Self {
Self {
image: Arc::clone(&self.image),
area: self.area,
first_row: self.first_row,
file: None,
}
}
}
impl PartialEq for ImagePlacement {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.image, &other.image)