tui: refactor ChatWidget and BottomPane to use Renderables (#5565)
- introduce RenderableItem to support both owned and borrowed children in composite Renderables - refactor some of our gnarlier manual layouts, BottomPane and ChatWidget, to use ColumnRenderable - Renderable and friends now handle cursor_pos()
This commit is contained in:
@@ -9,6 +9,7 @@ use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
@@ -233,7 +234,7 @@ impl App {
|
||||
tui.draw(
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||
|frame| {
|
||||
frame.render_widget_ref(&self.chat_widget, frame.area());
|
||||
self.chat_widget.render(frame.area(), frame.buffer);
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
|
||||
@@ -260,10 +260,6 @@ impl BottomPaneView for ApprovalOverlay {
|
||||
self.enqueue_request(request);
|
||||
None
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.list.cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ApprovalOverlay {
|
||||
@@ -274,6 +270,10 @@ impl Renderable for ApprovalOverlay {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.list.render(area, buf);
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.list.cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequestState {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::CancellationEvent;
|
||||
|
||||
@@ -27,11 +26,6 @@ pub(crate) trait BottomPaneView: Renderable {
|
||||
false
|
||||
}
|
||||
|
||||
/// Cursor position when this view is active.
|
||||
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to handle approval request; return the original value if not
|
||||
/// consumed.
|
||||
fn try_consume_approval_request(
|
||||
|
||||
@@ -35,6 +35,9 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::bottom_pane::prompt_args::prompt_argument_names;
|
||||
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
|
||||
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use crate::style::user_message_style;
|
||||
@@ -158,24 +161,6 @@ impl ChatComposer {
|
||||
this
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
.custom_footer_height()
|
||||
.unwrap_or_else(|| footer_height(footer_props));
|
||||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||||
let footer_total_height = footer_hint_height + footer_spacing;
|
||||
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
|
||||
self.textarea
|
||||
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
|
||||
+ 2
|
||||
+ match &self.active_popup {
|
||||
ActivePopup::None => footer_total_height,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
@@ -190,18 +175,9 @@ impl ChatComposer {
|
||||
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
||||
ActivePopup::None => Constraint::Max(footer_total_height),
|
||||
};
|
||||
let mut area = area;
|
||||
if area.height > 1 {
|
||||
area.height -= 1;
|
||||
area.y += 1;
|
||||
}
|
||||
let [composer_rect, popup_rect] =
|
||||
Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area);
|
||||
let mut textarea_rect = composer_rect;
|
||||
textarea_rect.width = textarea_rect.width.saturating_sub(
|
||||
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
|
||||
);
|
||||
textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS);
|
||||
Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area);
|
||||
let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1));
|
||||
[composer_rect, textarea_rect, popup_rect]
|
||||
}
|
||||
|
||||
@@ -213,12 +189,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let [_, textarea_rect, _] = self.layout_areas(area);
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
|
||||
/// Returns true if the composer currently contains no user input.
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.textarea.is_empty()
|
||||
@@ -1541,8 +1511,32 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for ChatComposer {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
impl Renderable for ChatComposer {
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let [_, textarea_rect, _] = self.layout_areas(area);
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = self
|
||||
.custom_footer_height()
|
||||
.unwrap_or_else(|| footer_height(footer_props));
|
||||
let footer_spacing = Self::footer_spacing(footer_hint_height);
|
||||
let footer_total_height = footer_hint_height + footer_spacing;
|
||||
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
|
||||
self.textarea
|
||||
.desired_height(width.saturating_sub(COLS_WITH_MARGIN))
|
||||
+ 2
|
||||
+ match &self.active_popup {
|
||||
ActivePopup::None => footer_total_height,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area);
|
||||
match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
@@ -1591,16 +1585,15 @@ impl WidgetRef for ChatComposer {
|
||||
}
|
||||
}
|
||||
let style = user_message_style();
|
||||
let mut block_rect = composer_rect;
|
||||
block_rect.y = composer_rect.y.saturating_sub(1);
|
||||
block_rect.height = composer_rect.height.saturating_add(1);
|
||||
Block::default().style(style).render_ref(block_rect, buf);
|
||||
Block::default().style(style).render_ref(composer_rect, buf);
|
||||
if !textarea_rect.is_empty() {
|
||||
buf.set_span(
|
||||
composer_rect.x,
|
||||
composer_rect.y,
|
||||
textarea_rect.x - LIVE_PREFIX_COLS,
|
||||
textarea_rect.y,
|
||||
&"›".bold(),
|
||||
composer_rect.width,
|
||||
textarea_rect.width,
|
||||
);
|
||||
}
|
||||
|
||||
let mut state = self.textarea_state.borrow_mut();
|
||||
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
||||
@@ -1692,7 +1685,7 @@ mod tests {
|
||||
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
composer.render_ref(area, &mut buf);
|
||||
composer.render(area, &mut buf);
|
||||
|
||||
let row_to_string = |y: u16| {
|
||||
let mut row = String::new();
|
||||
@@ -1756,7 +1749,7 @@ mod tests {
|
||||
let height = footer_lines + footer_spacing + 8;
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(composer, f.area()))
|
||||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
@@ -2276,7 +2269,7 @@ mod tests {
|
||||
}
|
||||
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(composer, f.area()))
|
||||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||||
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
||||
|
||||
insta::assert_snapshot!(name, terminal.backend());
|
||||
@@ -2302,12 +2295,12 @@ mod tests {
|
||||
// Type "/mo" humanlike so paste-burst doesn’t interfere.
|
||||
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
|
||||
|
||||
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
|
||||
let mut terminal = match Terminal::new(TestBackend::new(60, 5)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("Failed to create terminal: {e}"),
|
||||
};
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(composer, f.area()))
|
||||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||||
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
|
||||
|
||||
// Visual snapshot should show the slash popup with /model as the first entry.
|
||||
|
||||
@@ -103,26 +103,6 @@ impl BottomPaneView for CustomPromptView {
|
||||
self.textarea.insert_str(&pasted);
|
||||
true
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if area.height < 2 || area.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
||||
if text_area_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
let top_line_count = 1u16 + extra_offset;
|
||||
let textarea_rect = Rect {
|
||||
x: area.x.saturating_add(2),
|
||||
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for CustomPromptView {
|
||||
@@ -232,6 +212,26 @@ impl Renderable for CustomPromptView {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if area.height < 2 || area.width <= 2 {
|
||||
return None;
|
||||
}
|
||||
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
||||
if text_area_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
let top_line_count = 1u16 + extra_offset;
|
||||
let textarea_rect = Rect {
|
||||
x: area.x.saturating_add(2),
|
||||
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: text_area_height,
|
||||
};
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomPromptView {
|
||||
|
||||
@@ -163,6 +163,12 @@ impl BottomPaneView for FeedbackNoteView {
|
||||
self.textarea.insert_str(&pasted);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for FeedbackNoteView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
1u16 + self.input_height(width) + 3u16
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if area.height < 2 || area.width <= 2 {
|
||||
@@ -182,12 +188,6 @@ impl BottomPaneView for FeedbackNoteView {
|
||||
let state = *self.textarea_state.borrow();
|
||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for FeedbackNoteView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
1u16 + self.input_height(width) + 3u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
|
||||
@@ -3,19 +3,16 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::Renderable as _;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_overlay;
|
||||
@@ -126,77 +123,6 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
let top_margin = 1;
|
||||
|
||||
// Base height depends on whether a modal/overlay is active.
|
||||
let base = match self.active_view().as_ref() {
|
||||
Some(view) => view.desired_height(width),
|
||||
None => {
|
||||
let status_height = self
|
||||
.status
|
||||
.as_ref()
|
||||
.map_or(0, |status| status.desired_height(width));
|
||||
let queue_height = self.queued_user_messages.desired_height(width);
|
||||
let spacing_height = if status_height == 0 && queue_height == 0 {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
self.composer
|
||||
.desired_height(width)
|
||||
.saturating_add(spacing_height)
|
||||
.saturating_add(status_height)
|
||||
.saturating_add(queue_height)
|
||||
}
|
||||
};
|
||||
// Account for bottom padding rows. Top spacing is handled in layout().
|
||||
base.saturating_add(top_margin)
|
||||
}
|
||||
|
||||
fn layout(&self, area: Rect) -> [Rect; 2] {
|
||||
// At small heights, bottom pane takes the entire height.
|
||||
let top_margin = if area.height <= 1 { 0 } else { 1 };
|
||||
|
||||
let area = area.inset(Insets::tlbr(top_margin, 0, 0, 0));
|
||||
if self.active_view().is_some() {
|
||||
return [Rect::ZERO, area];
|
||||
}
|
||||
let has_queue = !self.queued_user_messages.messages.is_empty();
|
||||
let mut status_height = self
|
||||
.status
|
||||
.as_ref()
|
||||
.map_or(0, |status| status.desired_height(area.width))
|
||||
.min(area.height.saturating_sub(1));
|
||||
if has_queue && status_height > 1 {
|
||||
status_height = status_height.saturating_sub(1);
|
||||
}
|
||||
let combined_height = status_height
|
||||
.saturating_add(self.queued_user_messages.desired_height(area.width))
|
||||
.min(area.height.saturating_sub(1));
|
||||
|
||||
let [status_area, _, content_area] = Layout::vertical([
|
||||
Constraint::Length(combined_height),
|
||||
Constraint::Length(if combined_height == 0 { 0 } else { 1 }),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.areas(area);
|
||||
[status_area, content_area]
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
// Hide the cursor whenever an overlay view is active (e.g. the
|
||||
// status indicator shown while a task is running, or approval modal).
|
||||
// In these states the textarea is not interactable, so we should not
|
||||
// show its caret.
|
||||
let [_, content] = self.layout(area);
|
||||
if let Some(view) = self.active_view() {
|
||||
view.cursor_pos(content)
|
||||
} else {
|
||||
self.composer.cursor_pos(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
// If a modal/view is active, handle it here; otherwise forward to composer.
|
||||
@@ -540,39 +466,36 @@ impl BottomPane {
|
||||
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
self.composer.take_recent_submission_images()
|
||||
}
|
||||
|
||||
fn as_renderable(&'_ self) -> RenderableItem<'_> {
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
} else {
|
||||
let mut flex = FlexRenderable::new();
|
||||
if let Some(status) = &self.status {
|
||||
flex.push(0, RenderableItem::Borrowed(status));
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
|
||||
if self.status.is_some() || !self.queued_user_messages.messages.is_empty() {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
let mut flex2 = FlexRenderable::new();
|
||||
flex2.push(1, RenderableItem::Owned(flex.into()));
|
||||
flex2.push(0, RenderableItem::Borrowed(&self.composer));
|
||||
RenderableItem::Owned(Box::new(flex2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let [top_area, content_area] = self.layout(area);
|
||||
|
||||
// When a modal view is active, it owns the whole content area.
|
||||
if let Some(view) = self.active_view() {
|
||||
view.render(content_area, buf);
|
||||
} else {
|
||||
let status_height = self
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|status| status.desired_height(top_area.width).min(top_area.height))
|
||||
.unwrap_or(0);
|
||||
if let Some(status) = &self.status
|
||||
&& status_height > 0
|
||||
{
|
||||
status.render_ref(top_area, buf);
|
||||
impl Renderable for BottomPane {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_renderable().render(area, buf);
|
||||
}
|
||||
|
||||
let queue_area = Rect {
|
||||
x: top_area.x,
|
||||
y: top_area.y.saturating_add(status_height),
|
||||
width: top_area.width,
|
||||
height: top_area.height.saturating_sub(status_height),
|
||||
};
|
||||
if queue_area.height > 0 {
|
||||
self.queued_user_messages.render(queue_area, buf);
|
||||
}
|
||||
|
||||
self.composer.render_ref(content_area, buf);
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_renderable().desired_height(width)
|
||||
}
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_renderable().cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,7 +522,7 @@ mod tests {
|
||||
|
||||
fn render_snapshot(pane: &BottomPane, area: Rect) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
pane.render(area, &mut buf);
|
||||
snapshot_buffer(&buf)
|
||||
}
|
||||
|
||||
@@ -651,7 +574,7 @@ mod tests {
|
||||
// Render and verify the top row does not include an overlay.
|
||||
let area = Rect::new(0, 0, 60, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
pane.render(area, &mut buf);
|
||||
|
||||
let mut r0 = String::new();
|
||||
for x in 0..area.width {
|
||||
@@ -665,7 +588,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn composer_shown_after_denied_while_task_running() {
|
||||
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
@@ -700,14 +623,14 @@ mod tests {
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
let mut row1 = String::new();
|
||||
pane.render(area, &mut buf);
|
||||
let mut row0 = String::new();
|
||||
for x in 0..area.width {
|
||||
row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row1.contains("Working"),
|
||||
"expected Working header after denial on row 1: {row1:?}"
|
||||
row0.contains("Working"),
|
||||
"expected Working header after denial on row 0: {row0:?}"
|
||||
);
|
||||
|
||||
// Composer placeholder should be visible somewhere below.
|
||||
@@ -726,9 +649,6 @@ mod tests {
|
||||
found_composer,
|
||||
"expected composer visible under status line"
|
||||
);
|
||||
|
||||
// Drain the channel to avoid unused warnings.
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -750,16 +670,10 @@ mod tests {
|
||||
// Use a height that allows the status line to be visible above the composer.
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
pane.render(area, &mut buf);
|
||||
|
||||
let mut row0 = String::new();
|
||||
for x in 0..area.width {
|
||||
row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header: {row0:?}"
|
||||
);
|
||||
let bufs = snapshot_buffer(&buf);
|
||||
assert!(bufs.contains("• Working"), "expected Working header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -791,36 +705,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_hidden_when_height_too_small() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Height=2 → composer takes the full space; status collapses when there is no room.
|
||||
let area2 = Rect::new(0, 0, 20, 2);
|
||||
assert_snapshot!(
|
||||
"status_hidden_when_height_too_small_height_2",
|
||||
render_snapshot(&pane, area2)
|
||||
);
|
||||
|
||||
// Height=1 → no padding; single row is the composer (status hidden).
|
||||
let area1 = Rect::new(0, 0, 20, 1);
|
||||
assert_snapshot!(
|
||||
"status_hidden_when_height_too_small_height_1",
|
||||
render_snapshot(&pane, area1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_messages_visible_when_status_hidden_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -4,5 +4,6 @@ expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /mo "
|
||||
" "
|
||||
" /model choose what model and reasoning effort to use "
|
||||
" /mention mention a file "
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
• Working (0s • esc to interru
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
• Working (0s • esc to interrupt)
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area2)"
|
||||
---
|
||||
|
||||
› Ask Codex to do a
|
||||
@@ -54,15 +54,11 @@ use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use rand::Rng;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tracing::debug;
|
||||
@@ -92,8 +88,12 @@ use crate::history_cell::McpToolCallCell;
|
||||
use crate::markdown::append_markdown;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::onboarding::WSL_INSTRUCTIONS;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableExt;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::status::RateLimitSnapshotDisplay;
|
||||
use crate::text_formatting::truncate_text;
|
||||
@@ -293,6 +293,15 @@ impl From<String> for UserMessage {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for UserMessage {
|
||||
fn from(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
image_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
None
|
||||
@@ -951,27 +960,6 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height);
|
||||
let remaining = area.height.saturating_sub(bottom_min);
|
||||
|
||||
let active_desired = self
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.map_or(0, |c| c.desired_height(area.width) + 1);
|
||||
let active_height = active_desired.min(remaining);
|
||||
// Note: no header area; remaining is not used beyond computing active height.
|
||||
|
||||
let header_height = 0u16;
|
||||
|
||||
Layout::vertical([
|
||||
Constraint::Length(header_height),
|
||||
Constraint::Length(active_height),
|
||||
Constraint::Min(bottom_min),
|
||||
])
|
||||
.areas(area)
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
common: ChatWidgetInit,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
@@ -1100,14 +1088,6 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.bottom_pane.desired_height(width)
|
||||
+ self
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.map_or(0, |c| c.desired_height(width) + 1)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
@@ -1158,12 +1138,7 @@ impl ChatWidget {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.queued_user_messages.push_back(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
@@ -1220,7 +1195,7 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
self.submit_text_message(INIT_PROMPT.to_string());
|
||||
self.submit_user_message(INIT_PROMPT.to_string().into());
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
self.clear_token_usage();
|
||||
@@ -1368,6 +1343,15 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||||
}
|
||||
|
||||
fn queue_user_message(&mut self, user_message: UserMessage) {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.queued_user_messages.push_back(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
@@ -2322,16 +2306,6 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
/// Programmatically submit a user text message as if typed in the
|
||||
/// composer. The text will be added to conversation history and sent to
|
||||
/// the agent.
|
||||
pub(crate) fn submit_text_message(&mut self, text: String) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.submit_user_message(text.into());
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> TokenUsage {
|
||||
self.token_info
|
||||
.as_ref()
|
||||
@@ -2357,30 +2331,34 @@ impl ChatWidget {
|
||||
self.token_info = None;
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let [_, _, bottom_pane_area] = self.layout_areas(area);
|
||||
self.bottom_pane.cursor_pos(bottom_pane_area)
|
||||
fn as_renderable(&self) -> RenderableItem<'_> {
|
||||
let active_cell_renderable = match &self.active_cell {
|
||||
Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)),
|
||||
None => RenderableItem::Owned(Box::new(())),
|
||||
};
|
||||
let mut flex = FlexRenderable::new();
|
||||
flex.push(1, active_cell_renderable);
|
||||
flex.push(
|
||||
0,
|
||||
RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)),
|
||||
);
|
||||
RenderableItem::Owned(Box::new(flex))
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
if !active_cell_area.is_empty()
|
||||
&& let Some(cell) = &self.active_cell
|
||||
{
|
||||
let mut area = active_cell_area;
|
||||
area.y = area.y.saturating_add(1);
|
||||
area.height = area.height.saturating_sub(1);
|
||||
if let Some(exec) = cell.as_any().downcast_ref::<ExecCell>() {
|
||||
exec.render_ref(area, buf);
|
||||
} else if let Some(tool) = cell.as_any().downcast_ref::<McpToolCallCell>() {
|
||||
tool.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
impl Renderable for ChatWidget {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_renderable().render(area, buf);
|
||||
self.last_rendered_width.set(Some(area.width as usize));
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.as_renderable().desired_height(width)
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.as_renderable().cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
enum Notification {
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1470
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1500
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1500
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Thinking (0s • esc to interrupt) "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
↳ Hello, world! 0
|
||||
↳ Hello, world! 1
|
||||
↳ Hello, world! 2
|
||||
↳ Hello, world! 3
|
||||
↳ Hello, world! 4
|
||||
↳ Hello, world! 5
|
||||
↳ Hello, world! 6
|
||||
↳ Hello, world! 7
|
||||
↳ Hello, world! 8
|
||||
↳ Hello, world! 9
|
||||
↳ Hello, world! 10
|
||||
↳ Hello, world! 11
|
||||
↳ Hello, world! 12
|
||||
↳ Hello, world! 13
|
||||
↳ Hello, world! 14
|
||||
↳ Hello, world! 15
|
||||
↳ Hello, world! 16
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
@@ -424,7 +424,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
|
||||
// The approval modal should display the command snippet for user confirmation.
|
||||
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
|
||||
|
||||
// Approve via keyboard and verify a concise decision history line is added
|
||||
@@ -465,7 +465,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
|
||||
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
let mut saw_first_line = false;
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
@@ -1039,7 +1039,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() {
|
||||
let height = chat.desired_height(width);
|
||||
let area = ratatui::layout::Rect::new(0, 0, width, height);
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
|
||||
let mut blob = String::new();
|
||||
for y in 0..area.height {
|
||||
@@ -1267,7 +1267,7 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
let height = chat.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
@@ -1289,7 +1289,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
|
||||
let height = chat.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
|
||||
let mut lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
@@ -1701,7 +1701,7 @@ fn approval_modal_exec_snapshot() {
|
||||
terminal.set_viewport_area(viewport);
|
||||
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw approval modal");
|
||||
assert!(
|
||||
terminal
|
||||
@@ -1742,7 +1742,7 @@ fn approval_modal_exec_without_reason_snapshot() {
|
||||
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw approval modal (no reason)");
|
||||
assert_snapshot!(
|
||||
"approval_modal_exec_no_reason",
|
||||
@@ -1781,7 +1781,7 @@ fn approval_modal_patch_snapshot() {
|
||||
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw patch approval modal");
|
||||
assert_snapshot!(
|
||||
"approval_modal_patch",
|
||||
@@ -1873,7 +1873,7 @@ fn ui_snapshots_small_heights_idle() {
|
||||
let name = format!("chat_small_idle_h{h}");
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw chat idle");
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
@@ -1903,7 +1903,7 @@ fn ui_snapshots_small_heights_task_running() {
|
||||
let name = format!("chat_small_running_h{h}");
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw chat running");
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
@@ -1953,7 +1953,7 @@ fn status_widget_and_approval_modal_snapshot() {
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
||||
.expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw status + approval modal");
|
||||
assert_snapshot!("status_widget_and_approval_modal", terminal.backend());
|
||||
}
|
||||
@@ -1982,7 +1982,7 @@ fn status_widget_active_snapshot() {
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
||||
.expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw status widget");
|
||||
assert_snapshot!("status_widget_active", terminal.backend());
|
||||
}
|
||||
@@ -2017,7 +2017,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
|
||||
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
let mut saw_summary = false;
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
@@ -2304,7 +2304,7 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
||||
// Render and ensure the approval modal title is present
|
||||
let area = Rect::new(0, 0, 80, 12);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
|
||||
let mut contains_title = false;
|
||||
for y in 0..area.height {
|
||||
@@ -2358,7 +2358,7 @@ fn apply_patch_request_shows_diff_summary() {
|
||||
|
||||
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
chat.render(area, &mut buf);
|
||||
|
||||
let mut saw_header = false;
|
||||
let mut saw_line1 = false;
|
||||
@@ -2683,7 +2683,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
}
|
||||
|
||||
term.draw(|f| {
|
||||
(&chat).render_ref(f.area(), f.buffer_mut());
|
||||
chat.render(f.area(), f.buffer_mut());
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -2781,3 +2781,28 @@ printf 'fenced within fenced\n'
|
||||
|
||||
assert_snapshot!(term.backend().vt100().screen().contents());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatwidget_tall() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
chat.handle_codex_event(Event {
|
||||
id: "t1".into(),
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: None,
|
||||
}),
|
||||
});
|
||||
for i in 0..30 {
|
||||
chat.queue_user_message(format!("Hello, world! {i}").into());
|
||||
}
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 24;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
let desired_height = chat.desired_height(width).min(height);
|
||||
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
|
||||
term.draw(|f| {
|
||||
chat.render(f.area(), f.buffer_mut());
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(term.backend().vt100().screen().contents());
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
|
||||
rows.push(Box::new(path));
|
||||
rows.push(Box::new(RtLine::from("")));
|
||||
rows.push(Box::new(InsetRenderable::new(
|
||||
row.change,
|
||||
Box::new(row.change) as Box<dyn Renderable>,
|
||||
Insets::tlbr(0, 2, 0, 0),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ use itertools::Itertools;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use textwrap::WordSplitter;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -205,31 +202,6 @@ impl HistoryCell for ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ExecCell {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let content_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: area.height,
|
||||
};
|
||||
let lines = self.display_lines(area.width);
|
||||
let max_rows = area.height as usize;
|
||||
let rendered = if lines.len() > max_rows {
|
||||
lines[lines.len() - max_rows..].to_vec()
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
|
||||
Paragraph::new(Text::from(rendered))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(content_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::text_formatting::truncate_text;
|
||||
@@ -45,7 +46,6 @@ use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
@@ -99,6 +99,24 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Box<dyn HistoryCell> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.display_lines(area.width);
|
||||
let y = if area.height == 0 {
|
||||
0
|
||||
} else {
|
||||
let overflow = lines.len().saturating_sub(usize::from(area.height));
|
||||
u16::try_from(overflow).unwrap_or(u16::MAX)
|
||||
};
|
||||
Paragraph::new(Text::from(lines))
|
||||
.scroll((y, 0))
|
||||
.render(area, buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
HistoryCell::desired_height(self.as_ref(), width)
|
||||
}
|
||||
}
|
||||
|
||||
impl dyn HistoryCell {
|
||||
pub(crate) fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
@@ -929,23 +947,6 @@ impl HistoryCell for McpToolCallCell {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &McpToolCallCell {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let lines = self.display_lines(area.width);
|
||||
let max_rows = area.height as usize;
|
||||
let rendered = if lines.len() > max_rows {
|
||||
lines[lines.len() - max_rows..].to_vec()
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
|
||||
Text::from(rendered).render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_mcp_tool_call(
|
||||
call_id: String,
|
||||
invocation: McpInvocation,
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
/// Action returned from feeding a key event into the ComposerInput.
|
||||
pub enum ComposerAction {
|
||||
@@ -94,7 +94,7 @@ impl ComposerInput {
|
||||
|
||||
/// Render the input into the provided buffer at `area`.
|
||||
pub fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
WidgetRef::render_ref(&self.inner, area, buf);
|
||||
self.inner.render(area, buf);
|
||||
}
|
||||
|
||||
/// Return true if a paste-burst detection is currently active.
|
||||
|
||||
@@ -13,9 +13,49 @@ use crate::render::RectExt as _;
|
||||
pub trait Renderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
fn desired_height(&self, width: u16) -> u16;
|
||||
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Renderable + 'static> From<R> for Box<dyn Renderable> {
|
||||
pub enum RenderableItem<'a> {
|
||||
Owned(Box<dyn Renderable + 'a>),
|
||||
Borrowed(&'a dyn Renderable),
|
||||
}
|
||||
|
||||
impl<'a> Renderable for RenderableItem<'a> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
match self {
|
||||
RenderableItem::Owned(child) => child.render(area, buf),
|
||||
RenderableItem::Borrowed(child) => child.render(area, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
match self {
|
||||
RenderableItem::Owned(child) => child.desired_height(width),
|
||||
RenderableItem::Borrowed(child) => child.desired_height(width),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
match self {
|
||||
RenderableItem::Owned(child) => child.cursor_pos(area),
|
||||
RenderableItem::Borrowed(child) => child.cursor_pos(area),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
|
||||
fn from(value: Box<dyn Renderable + 'a>) -> Self {
|
||||
RenderableItem::Owned(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, R> From<R> for Box<dyn Renderable + 'a>
|
||||
where
|
||||
R: Renderable + 'a,
|
||||
{
|
||||
fn from(value: R) -> Self {
|
||||
Box::new(value)
|
||||
}
|
||||
@@ -98,11 +138,11 @@ impl<R: Renderable> Renderable for Arc<R> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ColumnRenderable {
|
||||
children: Vec<Box<dyn Renderable>>,
|
||||
pub struct ColumnRenderable<'a> {
|
||||
children: Vec<RenderableItem<'a>>,
|
||||
}
|
||||
|
||||
impl Renderable for ColumnRenderable {
|
||||
impl Renderable for ColumnRenderable<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut y = area.y;
|
||||
for child in &self.children {
|
||||
@@ -121,29 +161,166 @@ impl Renderable for ColumnRenderable {
|
||||
.map(|child| child.desired_height(width))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Returns the cursor position of the first child that has a cursor position, offset by the
|
||||
/// child's position in the column.
|
||||
///
|
||||
/// It is generally assumed that either zero or one child will have a cursor position.
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let mut y = area.y;
|
||||
for child in &self.children {
|
||||
let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width))
|
||||
.intersection(area);
|
||||
if !child_area.is_empty()
|
||||
&& let Some((px, py)) = child.cursor_pos(child_area)
|
||||
{
|
||||
return Some((px, py));
|
||||
}
|
||||
y += child_area.height;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ColumnRenderable {
|
||||
impl<'a> ColumnRenderable<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self::with(vec![])
|
||||
Self { children: vec![] }
|
||||
}
|
||||
|
||||
pub fn with(children: impl IntoIterator<Item = Box<dyn Renderable>>) -> Self {
|
||||
pub fn with<I, T>(children: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<RenderableItem<'a>>,
|
||||
{
|
||||
Self {
|
||||
children: children.into_iter().collect(),
|
||||
children: children.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, child: impl Into<Box<dyn Renderable>>) {
|
||||
self.children.push(child.into());
|
||||
pub fn push(&mut self, child: impl Into<Box<dyn Renderable + 'a>>) {
|
||||
self.children.push(RenderableItem::Owned(child.into()));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn push_ref<R>(&mut self, child: &'a R)
|
||||
where
|
||||
R: Renderable + 'a,
|
||||
{
|
||||
self.children
|
||||
.push(RenderableItem::Borrowed(child as &'a dyn Renderable));
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RowRenderable {
|
||||
children: Vec<(u16, Box<dyn Renderable>)>,
|
||||
pub struct FlexChild<'a> {
|
||||
flex: i32,
|
||||
child: RenderableItem<'a>,
|
||||
}
|
||||
|
||||
impl Renderable for RowRenderable {
|
||||
pub struct FlexRenderable<'a> {
|
||||
children: Vec<FlexChild<'a>>,
|
||||
}
|
||||
|
||||
/// Lays out children in a column, with the ability to specify a flex factor for each child.
|
||||
///
|
||||
/// Children with flex factor > 0 will be allocated the remaining space after the non-flex children,
|
||||
/// proportional to the flex factor.
|
||||
impl<'a> FlexRenderable<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self { children: vec![] }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, flex: i32, child: impl Into<RenderableItem<'a>>) {
|
||||
self.children.push(FlexChild {
|
||||
flex,
|
||||
child: child.into(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Loosely inspired by Flutter's Flex widget.
|
||||
///
|
||||
/// Ref https://github.com/flutter/flutter/blob/3fd81edbf1e015221e143c92b2664f4371bdc04a/packages/flutter/lib/src/rendering/flex.dart#L1205-L1209
|
||||
fn allocate(&self, area: Rect) -> Vec<Rect> {
|
||||
let mut allocated_rects = Vec::with_capacity(self.children.len());
|
||||
let mut child_sizes = vec![0; self.children.len()];
|
||||
let mut allocated_size = 0;
|
||||
let mut total_flex = 0;
|
||||
|
||||
// 1. Allocate space to non-flex children.
|
||||
let max_size = area.height;
|
||||
let mut last_flex_child_idx = 0;
|
||||
for (i, FlexChild { flex, child }) in self.children.iter().enumerate() {
|
||||
if *flex > 0 {
|
||||
total_flex += flex;
|
||||
last_flex_child_idx = i;
|
||||
} else {
|
||||
child_sizes[i] = child
|
||||
.desired_height(area.width)
|
||||
.min(max_size.saturating_sub(allocated_size));
|
||||
allocated_size += child_sizes[i];
|
||||
}
|
||||
}
|
||||
let free_space = max_size.saturating_sub(allocated_size);
|
||||
// 2. Allocate space to flex children, proportional to their flex factor.
|
||||
let mut allocated_flex_space = 0;
|
||||
if total_flex > 0 {
|
||||
let space_per_flex = free_space / total_flex as u16;
|
||||
for (i, FlexChild { flex, child }) in self.children.iter().enumerate() {
|
||||
if *flex > 0 {
|
||||
// Last flex child gets all the remaining space, to prevent a rounding error
|
||||
// from not allocating all the space.
|
||||
let max_child_extent = if i == last_flex_child_idx {
|
||||
free_space - allocated_flex_space
|
||||
} else {
|
||||
space_per_flex * *flex as u16
|
||||
};
|
||||
let child_size = child.desired_height(area.width).min(max_child_extent);
|
||||
child_sizes[i] = child_size;
|
||||
allocated_size += child_size;
|
||||
allocated_flex_space += child_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut y = area.y;
|
||||
for size in child_sizes {
|
||||
let child_area = Rect::new(area.x, y, area.width, size);
|
||||
allocated_rects.push(child_area);
|
||||
y += child_area.height;
|
||||
}
|
||||
allocated_rects
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Renderable for FlexRenderable<'a> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.allocate(area)
|
||||
.into_iter()
|
||||
.zip(self.children.iter())
|
||||
.for_each(|(rect, child)| {
|
||||
child.child.render(rect, buf);
|
||||
});
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.allocate(Rect::new(0, 0, width, u16::MAX))
|
||||
.last()
|
||||
.map(|rect| rect.bottom())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.allocate(area)
|
||||
.into_iter()
|
||||
.zip(self.children.iter())
|
||||
.find_map(|(rect, child)| child.child.cursor_pos(rect))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RowRenderable<'a> {
|
||||
children: Vec<(u16, RenderableItem<'a>)>,
|
||||
}
|
||||
|
||||
impl Renderable for RowRenderable<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut x = area.x;
|
||||
for (width, child) in &self.children {
|
||||
@@ -172,24 +349,49 @@ impl Renderable for RowRenderable {
|
||||
}
|
||||
max_height
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let mut x = area.x;
|
||||
for (width, child) in &self.children {
|
||||
let available_width = area.width.saturating_sub(x - area.x);
|
||||
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
|
||||
if !child_area.is_empty()
|
||||
&& let Some(pos) = child.cursor_pos(child_area)
|
||||
{
|
||||
return Some(pos);
|
||||
}
|
||||
x = x.saturating_add(*width);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl RowRenderable {
|
||||
impl<'a> RowRenderable<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self { children: vec![] }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, width: u16, child: impl Into<Box<dyn Renderable>>) {
|
||||
self.children.push((width, child.into()));
|
||||
self.children
|
||||
.push((width, RenderableItem::Owned(child.into())));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn push_ref<R>(&mut self, width: u16, child: &'a R)
|
||||
where
|
||||
R: Renderable + 'a,
|
||||
{
|
||||
self.children
|
||||
.push((width, RenderableItem::Borrowed(child as &'a dyn Renderable)));
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InsetRenderable {
|
||||
child: Box<dyn Renderable>,
|
||||
pub struct InsetRenderable<'a> {
|
||||
child: RenderableItem<'a>,
|
||||
insets: Insets,
|
||||
}
|
||||
|
||||
impl Renderable for InsetRenderable {
|
||||
impl<'a> Renderable for InsetRenderable<'a> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.child.render(area.inset(self.insets), buf);
|
||||
}
|
||||
@@ -199,10 +401,13 @@ impl Renderable for InsetRenderable {
|
||||
+ self.insets.top
|
||||
+ self.insets.bottom
|
||||
}
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.child.cursor_pos(area.inset(self.insets))
|
||||
}
|
||||
}
|
||||
|
||||
impl InsetRenderable {
|
||||
pub fn new(child: impl Into<Box<dyn Renderable>>, insets: Insets) -> Self {
|
||||
impl<'a> InsetRenderable<'a> {
|
||||
pub fn new(child: impl Into<RenderableItem<'a>>, insets: Insets) -> Self {
|
||||
Self {
|
||||
child: child.into(),
|
||||
insets,
|
||||
@@ -210,15 +415,17 @@ impl InsetRenderable {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RenderableExt {
|
||||
fn inset(self, insets: Insets) -> Box<dyn Renderable>;
|
||||
pub trait RenderableExt<'a> {
|
||||
fn inset(self, insets: Insets) -> RenderableItem<'a>;
|
||||
}
|
||||
|
||||
impl<R: Into<Box<dyn Renderable>>> RenderableExt for R {
|
||||
fn inset(self, insets: Insets) -> Box<dyn Renderable> {
|
||||
Box::new(InsetRenderable {
|
||||
child: self.into(),
|
||||
insets,
|
||||
})
|
||||
impl<'a, R> RenderableExt<'a> for R
|
||||
where
|
||||
R: Renderable + 'a,
|
||||
{
|
||||
fn inset(self, insets: Insets) -> RenderableItem<'a> {
|
||||
let child: RenderableItem<'a> =
|
||||
RenderableItem::Owned(Box::new(self) as Box<dyn Renderable + 'a>);
|
||||
RenderableItem::Owned(Box::new(InsetRenderable { child, insets }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::key_hint;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
@@ -62,10 +63,6 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, _width: u16) -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt(&self) {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
}
|
||||
@@ -75,15 +72,15 @@ impl StatusIndicatorWidget {
|
||||
self.header = header;
|
||||
}
|
||||
|
||||
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
|
||||
self.show_interrupt_hint = visible;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn header(&self) -> &str {
|
||||
&self.header
|
||||
}
|
||||
|
||||
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
|
||||
self.show_interrupt_hint = visible;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn interrupt_hint_visible(&self) -> bool {
|
||||
self.show_interrupt_hint
|
||||
@@ -131,8 +128,12 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for StatusIndicatorWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
impl Renderable for StatusIndicatorWidget {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -200,7 +201,7 @@ mod tests {
|
||||
// Render into a fixed-size test terminal and snapshot the backend.
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
@@ -214,7 +215,7 @@ mod tests {
|
||||
// Render into a fixed-size test terminal and snapshot the backend.
|
||||
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render_ref(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user