mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
|
||||
+22
@@ -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…·····
|
||||
····· ·····
|
||||
····································
|
||||
····································
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user