mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
refactor(tui): add retained conversation viewport
This commit is contained in:
@@ -424,7 +424,7 @@ impl App {
|
||||
if matches!(&event, TuiEvent::Draw | TuiEvent::Resize)
|
||||
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
|
||||
{
|
||||
let active_key = self.chat_widget.active_cell_transcript_key();
|
||||
let active_key = self.chat_widget.active_cell_render_key();
|
||||
let chat_widget = &self.chat_widget;
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
let width = frame.area().width.max(1);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! visible immediately.
|
||||
//!
|
||||
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
|
||||
//! during draws using `active_cell_transcript_key()` and
|
||||
//! during draws using `active_cell_render_key()` and
|
||||
//! `active_cell_transcript_hyperlink_lines()`. The
|
||||
//! cache key is designed to change when the active cell mutates in place or when its transcript
|
||||
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
|
||||
@@ -754,12 +754,12 @@ enum CodexOpTarget {
|
||||
AppEvent,
|
||||
}
|
||||
|
||||
/// Snapshot of active-cell state that affects transcript overlay rendering.
|
||||
/// Snapshot of active-cell state that affects retained viewport rendering.
|
||||
///
|
||||
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
|
||||
/// it cheaply decide when to recompute that tail as the active cell evolves.
|
||||
/// Retained views keep a cached "live tail" for the in-flight cell; this key lets them cheaply
|
||||
/// decide when to recompute that tail as the active cell evolves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ActiveCellTranscriptKey {
|
||||
pub(crate) struct ActiveCellRenderKey {
|
||||
/// Cache-busting revision for in-place updates.
|
||||
///
|
||||
/// Many active cells are updated incrementally while streaming (for example when exec groups
|
||||
@@ -1921,7 +1921,7 @@ impl ChatWidget {
|
||||
/// If callers mutate the active cell's transcript output without bumping the revision (or
|
||||
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
|
||||
/// the main viewport updates.
|
||||
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
|
||||
pub(crate) fn active_cell_render_key(&self) -> Option<ActiveCellRenderKey> {
|
||||
let cell = self.transcript.active_cell.as_ref();
|
||||
let hook_cell = self.active_hook_cell.as_ref();
|
||||
let token_activity_cell = self.pending_token_activity_output();
|
||||
@@ -1933,7 +1933,7 @@ impl ChatWidget {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(ActiveCellTranscriptKey {
|
||||
Some(ActiveCellRenderKey {
|
||||
revision: self.transcript.active_cell_revision,
|
||||
is_stream_continuation: cell
|
||||
.map(|cell| cell.is_stream_continuation())
|
||||
@@ -1985,6 +1985,40 @@ impl ChatWidget {
|
||||
(!lines.is_empty()).then_some(lines)
|
||||
}
|
||||
|
||||
/// Returns the active cells' main-viewport lines for a given terminal width.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn active_cell_display_hyperlink_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
) -> Option<Vec<HyperlinkLine>> {
|
||||
let mode = self.history_render_mode();
|
||||
let mut lines = Vec::new();
|
||||
let mut append = |cell: &dyn HistoryCell| {
|
||||
let cell_lines = cell.display_hyperlink_lines_for_mode(width, mode);
|
||||
if !cell_lines.is_empty() && !lines.is_empty() {
|
||||
lines.push(HyperlinkLine::from(""));
|
||||
}
|
||||
lines.extend(cell_lines);
|
||||
};
|
||||
if let Some(cell) = self.transcript.active_cell.as_deref() {
|
||||
append(cell);
|
||||
}
|
||||
if let Some(cell) = self
|
||||
.active_hook_cell
|
||||
.as_ref()
|
||||
.filter(|cell| cell.should_render())
|
||||
{
|
||||
append(cell);
|
||||
}
|
||||
if let Some(cell) = self.pending_token_activity_output() {
|
||||
append(cell);
|
||||
}
|
||||
if let Some(cell) = self.pending_rate_limit_reset_hint() {
|
||||
append(cell);
|
||||
}
|
||||
(!lines.is_empty()).then_some(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
|
||||
self.active_cell_transcript_hyperlink_lines(width)
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn bottom_pane_renderable(&self) -> RenderableItem<'_> {
|
||||
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
|
||||
bottom_pane: &self.bottom_pane,
|
||||
right_reserve: self.ambient_pet_wrap_reserved_cols(),
|
||||
}))
|
||||
.inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn as_renderable(&self) -> RenderableItem<'_> {
|
||||
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
|
||||
let active_cell_renderable = match &self.transcript.active_cell {
|
||||
@@ -46,16 +56,7 @@ impl ChatWidget {
|
||||
})),
|
||||
);
|
||||
}
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
|
||||
bottom_pane: &self.bottom_pane,
|
||||
right_reserve: active_cell_right_reserve,
|
||||
}))
|
||||
.inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
)),
|
||||
);
|
||||
flex.push(/*flex*/ 0, self.bottom_pane_renderable());
|
||||
RenderableItem::Owned(Box::new(flex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1957,6 +1957,29 @@ async fn ambient_pet_reduces_stream_width_and_composer_text_width() {
|
||||
assert!(!row_tail_is_blank(&disabled_row, /*start_col*/ 69));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bottom_pane_renderable_can_be_laid_out_independently() {
|
||||
use ratatui::Terminal;
|
||||
|
||||
let (chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let bottom_pane = chat.bottom_pane_renderable();
|
||||
let width = 48;
|
||||
let height = bottom_pane.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
|
||||
terminal
|
||||
.draw(|frame| bottom_pane.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render bottom pane");
|
||||
|
||||
assert_snapshot!(normalized_backend_snapshot(terminal.backend()), @r###"
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" gpt-5.5 default · /tmp/project "
|
||||
"###);
|
||||
}
|
||||
|
||||
fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option<String> {
|
||||
(0..buffer.area.height)
|
||||
.map(|y| {
|
||||
@@ -3816,6 +3839,10 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("active exec transcript lines")
|
||||
.len();
|
||||
let exec_only_display_line_count = chat
|
||||
.active_cell_display_hyperlink_lines(/*width*/ 80)
|
||||
.expect("active exec display lines")
|
||||
.len();
|
||||
|
||||
handle_hook_started(
|
||||
&mut chat,
|
||||
@@ -3829,6 +3856,10 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("active exec transcript lines");
|
||||
assert_eq!(hidden_hook_transcript.len(), exec_only_line_count);
|
||||
let hidden_hook_display = chat
|
||||
.active_cell_display_hyperlink_lines(/*width*/ 80)
|
||||
.expect("active exec display lines");
|
||||
assert_eq!(hidden_hook_display.len(), exec_only_display_line_count);
|
||||
|
||||
reveal_running_hooks(&mut chat);
|
||||
let visible_hook_lines = chat
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Retained conversation rendering for an application-owned terminal viewport.
|
||||
//!
|
||||
//! This module intentionally owns only transcript projection and bottom-follow state. The app
|
||||
//! decides when the terminal is owned and how much space remains after the bottom pane is laid
|
||||
//! out. Committed cells use their main-viewport representation; the more detailed `Ctrl+T`
|
||||
//! representation remains owned by `pager_overlay`.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::chatwidget::ActiveCellRenderKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::keymap::PagerKeymap;
|
||||
use crate::pager_overlay::PagerContent;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
|
||||
pub(crate) struct ConversationViewport {
|
||||
content: PagerContent,
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
render_mode: HistoryRenderMode,
|
||||
live_tail_key: Option<LiveTailKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct LiveTailKey {
|
||||
width: u16,
|
||||
revision: u64,
|
||||
is_stream_continuation: bool,
|
||||
animation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
impl ConversationViewport {
|
||||
pub(crate) fn new(
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
render_mode: HistoryRenderMode,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
let renderables = Self::render_cells(&cells, render_mode);
|
||||
Self {
|
||||
content: PagerContent::new(renderables, keymap),
|
||||
cells,
|
||||
render_mode,
|
||||
live_tail_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
self.content.render(area, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn push_cell(&mut self, cell: Arc<dyn HistoryCell>) {
|
||||
let follow_bottom = self.content.is_following_bottom();
|
||||
let had_prior_cells = !self.cells.is_empty();
|
||||
let tail_renderable = self.take_live_tail_renderable();
|
||||
let renderable = Self::cell_renderable(
|
||||
cell.clone(),
|
||||
self.render_mode,
|
||||
/*has_prior_cells*/ had_prior_cells,
|
||||
);
|
||||
self.cells.push(cell);
|
||||
self.content.push(renderable);
|
||||
|
||||
if let Some(tail) = tail_renderable {
|
||||
let tail = if !had_prior_cells
|
||||
&& self
|
||||
.live_tail_key
|
||||
.is_some_and(|key| !key.is_stream_continuation)
|
||||
{
|
||||
Self::with_leading_spacing(tail)
|
||||
} else {
|
||||
tail
|
||||
};
|
||||
self.content.push(tail);
|
||||
}
|
||||
if follow_bottom {
|
||||
self.content.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
|
||||
let follow_bottom = self.content.is_following_bottom();
|
||||
self.take_live_tail_renderable();
|
||||
self.live_tail_key = None;
|
||||
self.cells = cells;
|
||||
self.content
|
||||
.replace(Self::render_cells(&self.cells, self.render_mode));
|
||||
if follow_bottom {
|
||||
self.content.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
|
||||
if self.render_mode == render_mode {
|
||||
return;
|
||||
}
|
||||
let follow_bottom = self.content.is_following_bottom();
|
||||
self.take_live_tail_renderable();
|
||||
self.live_tail_key = None;
|
||||
self.render_mode = render_mode;
|
||||
self.content
|
||||
.replace(Self::render_cells(&self.cells, self.render_mode));
|
||||
if follow_bottom {
|
||||
self.content.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_live_tail(
|
||||
&mut self,
|
||||
width: u16,
|
||||
active_key: Option<ActiveCellRenderKey>,
|
||||
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
|
||||
) {
|
||||
let next_key = active_key.map(|key| LiveTailKey {
|
||||
width,
|
||||
revision: key.revision,
|
||||
is_stream_continuation: key.is_stream_continuation,
|
||||
animation_tick: key.animation_tick,
|
||||
});
|
||||
if self.live_tail_key == next_key {
|
||||
return;
|
||||
}
|
||||
|
||||
let follow_bottom = self.content.is_following_bottom();
|
||||
self.take_live_tail_renderable();
|
||||
self.live_tail_key = next_key;
|
||||
if let Some(key) = next_key {
|
||||
let lines = compute_lines(width).unwrap_or_default();
|
||||
if !lines.is_empty() {
|
||||
self.content.push(Self::live_tail_renderable(
|
||||
lines,
|
||||
!self.cells.is_empty(),
|
||||
key.is_stream_continuation,
|
||||
));
|
||||
}
|
||||
}
|
||||
if follow_bottom {
|
||||
self.content.scroll_to_bottom();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_following_bottom(&self) -> bool {
|
||||
self.content.is_following_bottom()
|
||||
}
|
||||
|
||||
pub(crate) fn committed_cell_count(&self) -> usize {
|
||||
self.cells.len()
|
||||
}
|
||||
|
||||
fn render_cells(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
render_mode: HistoryRenderMode,
|
||||
) -> Vec<Box<dyn Renderable>> {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, cell)| {
|
||||
Self::cell_renderable(
|
||||
cell.clone(),
|
||||
render_mode,
|
||||
/*has_prior_cells*/ index > 0,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cell_renderable(
|
||||
cell: Arc<dyn HistoryCell>,
|
||||
render_mode: HistoryRenderMode,
|
||||
has_prior_cells: bool,
|
||||
) -> Box<dyn Renderable> {
|
||||
let is_stream_continuation = cell.is_stream_continuation();
|
||||
let renderable: Box<dyn Renderable> = Box::new(ConversationCellRenderable {
|
||||
cell,
|
||||
render_mode,
|
||||
cached_height: Cell::new(None),
|
||||
});
|
||||
if has_prior_cells && !is_stream_continuation {
|
||||
Self::with_leading_spacing(renderable)
|
||||
} else {
|
||||
renderable
|
||||
}
|
||||
}
|
||||
|
||||
fn live_tail_renderable(
|
||||
lines: Vec<HyperlinkLine>,
|
||||
has_prior_cells: bool,
|
||||
is_stream_continuation: bool,
|
||||
) -> Box<dyn Renderable> {
|
||||
let renderable: Box<dyn Renderable> = Box::new(HyperlinkLinesRenderable { lines });
|
||||
if has_prior_cells && !is_stream_continuation {
|
||||
Self::with_leading_spacing(renderable)
|
||||
} else {
|
||||
renderable
|
||||
}
|
||||
}
|
||||
|
||||
fn with_leading_spacing(renderable: Box<dyn Renderable>) -> Box<dyn Renderable> {
|
||||
Box::new(InsetRenderable::new(
|
||||
renderable,
|
||||
Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
|
||||
(self.content.len() > self.cells.len()).then(|| self.content.pop())?
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationCellRenderable {
|
||||
cell: Arc<dyn HistoryCell>,
|
||||
render_mode: HistoryRenderMode,
|
||||
cached_height: Cell<Option<(u16, u16)>>,
|
||||
}
|
||||
|
||||
impl Renderable for ConversationCellRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let hyperlink_lines = self
|
||||
.cell
|
||||
.display_hyperlink_lines_for_mode(area.width, self.render_mode);
|
||||
Paragraph::new(Text::from(visible_lines(hyperlink_lines.clone())))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, /*scroll_rows*/ 0);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if let Some((cached_width, height)) = self.cached_height.get()
|
||||
&& cached_width == width
|
||||
{
|
||||
return height;
|
||||
}
|
||||
let height = self.cell.desired_height_for_mode(width, self.render_mode);
|
||||
self.cached_height.set(Some((width, height)));
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
struct HyperlinkLinesRenderable {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl Renderable for HyperlinkLinesRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(Text::from(visible_lines(self.lines.clone())))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &self.lines, /*scroll_rows*/ 0);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(visible_lines(self.lines.clone())))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(/*default*/ 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "conversation_viewport_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::AgentMarkdownCell;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestCell {
|
||||
display: &'static str,
|
||||
raw: &'static str,
|
||||
transcript: &'static str,
|
||||
is_stream_continuation: bool,
|
||||
}
|
||||
|
||||
impl HistoryCell for TestCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
vec![self.display.into()]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![self.raw.into()]
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
vec![self.transcript.into()]
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
self.is_stream_continuation
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(display: &'static str) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(TestCell {
|
||||
display,
|
||||
raw: display,
|
||||
transcript: display,
|
||||
is_stream_continuation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn viewport(cells: Vec<Arc<dyn HistoryCell>>) -> ConversationViewport {
|
||||
ConversationViewport::new(
|
||||
cells,
|
||||
HistoryRenderMode::Rich,
|
||||
crate::keymap::RuntimeKeymap::defaults().pager,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_main_display_and_live_tail_without_pager_chrome() {
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(TestCell {
|
||||
display: "compact display",
|
||||
raw: "raw source",
|
||||
transcript: "expanded transcript detail",
|
||||
is_stream_continuation: false,
|
||||
})];
|
||||
let mut viewport = viewport(cells);
|
||||
viewport.sync_live_tail(
|
||||
/*width*/ 32,
|
||||
Some(ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(vec![HyperlinkLine::from("live tail")]),
|
||||
);
|
||||
let mut terminal =
|
||||
Terminal::new(TestBackend::new(/*width*/ 32, /*height*/ 6)).expect("create terminal");
|
||||
|
||||
terminal
|
||||
.draw(|frame| viewport.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render conversation viewport");
|
||||
|
||||
assert_snapshot!(terminal.backend(), @r###"
|
||||
"compact display "
|
||||
" "
|
||||
"live tail "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_between_rich_and_raw_cell_representations() {
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(TestCell {
|
||||
display: "rich display",
|
||||
raw: "raw source",
|
||||
transcript: "expanded transcript detail",
|
||||
is_stream_continuation: false,
|
||||
})];
|
||||
let mut viewport = viewport(cells);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 3,
|
||||
);
|
||||
let mut rich = Buffer::empty(area);
|
||||
viewport.render(area, &mut rich);
|
||||
|
||||
viewport.set_render_mode(HistoryRenderMode::Raw);
|
||||
let mut raw = Buffer::empty(area);
|
||||
viewport.render(area, &mut raw);
|
||||
|
||||
assert!(buffer_text(&rich, area).contains("rich display"));
|
||||
assert!(buffer_text(&raw, area).contains("raw source"));
|
||||
assert!(!buffer_text(&raw, area).contains("expanded transcript detail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_keeps_live_tail_after_committed_cells() {
|
||||
let mut viewport = viewport(Vec::new());
|
||||
viewport.sync_live_tail(
|
||||
/*width*/ 24,
|
||||
Some(ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(vec![HyperlinkLine::from("live tail")]),
|
||||
);
|
||||
|
||||
viewport.push_cell(cell("committed"));
|
||||
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 5,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
viewport.render(area, &mut buffer);
|
||||
let text = buffer_text(&buffer, area);
|
||||
let committed = text
|
||||
.find("committed")
|
||||
.expect("committed cell should render");
|
||||
let live_tail = text.find("live tail").expect("live tail should render");
|
||||
assert!(committed < live_tail);
|
||||
assert_eq!(viewport.committed_cell_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_cells_invalidates_and_respaces_the_live_tail() {
|
||||
let key = ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
};
|
||||
let mut viewport = viewport(Vec::new());
|
||||
viewport.sync_live_tail(/*width*/ 24, Some(key), |_| {
|
||||
Some(vec![HyperlinkLine::from("live tail")])
|
||||
});
|
||||
|
||||
viewport.replace_cells(vec![cell("replacement")]);
|
||||
viewport.sync_live_tail(/*width*/ 24, Some(key), |_| {
|
||||
Some(vec![HyperlinkLine::from("live tail")])
|
||||
});
|
||||
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
viewport.render(area, &mut buffer);
|
||||
let rows = buffer_text(&buffer, area);
|
||||
assert_eq!(
|
||||
rows.lines().map(str::trim_end).collect::<Vec<_>>(),
|
||||
vec!["replacement", "", "live tail", ""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_semantic_links_for_committed_and_live_content() {
|
||||
let committed_destination = "https://example.com/committed";
|
||||
let live_destination = "https://example.com/live";
|
||||
let committed: Arc<dyn HistoryCell> = Arc::new(AgentMarkdownCell::new(
|
||||
committed_destination.to_string(),
|
||||
std::path::Path::new("/tmp"),
|
||||
));
|
||||
let live = AgentMarkdownCell::new(live_destination.to_string(), std::path::Path::new("/tmp"));
|
||||
let mut viewport = viewport(vec![committed]);
|
||||
viewport.sync_live_tail(
|
||||
/*width*/ 28,
|
||||
Some(ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|width| Some(live.display_hyperlink_lines(width)),
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 28, /*height*/ 6,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
viewport.render(area, &mut buffer);
|
||||
|
||||
let rendered = buffer_text(&buffer, area);
|
||||
assert!(rendered.contains(&format!("\x1b]8;;{committed_destination}\x07")));
|
||||
assert!(rendered.contains(&format!("\x1b]8;;{live_destination}\x07")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrower_resize_stays_pinned_to_the_latest_cell() {
|
||||
let mut viewport = viewport(vec![
|
||||
cell("first long row that wraps"),
|
||||
cell("second long row that wraps"),
|
||||
cell("LATEST SENTINEL"),
|
||||
]);
|
||||
let wide = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
|
||||
);
|
||||
viewport.render(wide, &mut Buffer::empty(wide));
|
||||
|
||||
let narrow = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 4,
|
||||
);
|
||||
let mut buffer = Buffer::empty(narrow);
|
||||
viewport.render(narrow, &mut buffer);
|
||||
|
||||
assert!(viewport.is_following_bottom());
|
||||
assert!(buffer_text(&buffer, narrow).contains("SENTINEL"));
|
||||
}
|
||||
|
||||
fn buffer_text(buffer: &Buffer, area: Rect) -> String {
|
||||
let mut rows = Vec::new();
|
||||
for y in area.y..area.bottom() {
|
||||
let mut row = String::new();
|
||||
for x in area.x..area.right() {
|
||||
row.push_str(buffer[(x, y)].symbol());
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
rows.join("\n")
|
||||
}
|
||||
@@ -105,6 +105,9 @@ mod clipboard_paste;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
mod config_update;
|
||||
// Staged renderer primitive; the owned-screen app path is wired in a follow-up change.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
mod conversation_viewport;
|
||||
pub(crate) mod custom_terminal;
|
||||
mod pets;
|
||||
pub use custom_terminal::Terminal;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output).
|
||||
//!
|
||||
//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an
|
||||
//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and
|
||||
//! `ActiveCellRenderKey` and a function to compute the active cell transcript lines, and
|
||||
//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be
|
||||
//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell
|
||||
//! mutates in place or when its transcript output is time-dependent.
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::io::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::chatwidget::ActiveCellRenderKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint;
|
||||
@@ -169,7 +169,7 @@ impl PagerView {
|
||||
.scroll_offset
|
||||
.min(content_height.saturating_sub(content_area.height as usize));
|
||||
|
||||
self.render_content(content_area, buf);
|
||||
self.render_content(content_area, buf, /*empty_row_marker*/ Some('~'));
|
||||
|
||||
self.render_bottom_bar(area, content_area, buf, content_height);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ impl PagerView {
|
||||
header.dim().render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn render_content(&self, area: Rect, buf: &mut Buffer) {
|
||||
fn render_content(&self, area: Rect, buf: &mut Buffer, empty_row_marker: Option<char>) {
|
||||
let mut y = -(self.scroll_offset as isize);
|
||||
let mut drawn_bottom = area.y;
|
||||
for renderable in &self.renderables {
|
||||
@@ -207,11 +207,14 @@ impl PagerView {
|
||||
}
|
||||
}
|
||||
|
||||
let Some(empty_row_marker) = empty_row_marker else {
|
||||
return;
|
||||
};
|
||||
for y in drawn_bottom..area.bottom() {
|
||||
if area.width == 0 {
|
||||
break;
|
||||
}
|
||||
buf[(area.x, y)] = Cell::from('~');
|
||||
buf[(area.x, y)] = Cell::from(empty_row_marker);
|
||||
for x in area.x + 1..area.right() {
|
||||
buf[(x, y)] = Cell::from(' ');
|
||||
}
|
||||
@@ -311,6 +314,28 @@ impl PagerView {
|
||||
area.height = area.height.saturating_sub(2);
|
||||
area
|
||||
}
|
||||
|
||||
/// Render only scrollable content, without pager header, footer, or empty-row markers.
|
||||
///
|
||||
/// This is the shared rendering primitive for application-owned views that provide their own
|
||||
/// chrome. A view that was following the bottom stays pinned there when wrapping or the
|
||||
/// available height changes.
|
||||
fn render_content_only(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let follow_bottom = self.is_scrolled_to_bottom();
|
||||
Clear.render(area, buf);
|
||||
self.update_last_content_height(area.height);
|
||||
let content_height = self.content_height(area.width);
|
||||
self.last_rendered_height = Some(content_height);
|
||||
if let Some(idx) = self.pending_scroll_chunk.take() {
|
||||
self.ensure_chunk_visible(idx, area);
|
||||
} else if follow_bottom {
|
||||
self.scroll_offset = usize::MAX;
|
||||
}
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(content_height.saturating_sub(area.height as usize));
|
||||
self.render_content(area, buf, /*empty_row_marker*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
@@ -360,6 +385,52 @@ impl PagerView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable content state shared by full-frame views that provide their own chrome.
|
||||
pub(crate) struct PagerContent {
|
||||
view: PagerView,
|
||||
}
|
||||
|
||||
impl PagerContent {
|
||||
pub(crate) fn new(renderables: Vec<Box<dyn Renderable>>, keymap: PagerKeymap) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
renderables,
|
||||
/*title*/ String::new(),
|
||||
/*scroll_offset*/ usize::MAX,
|
||||
keymap,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
self.view.render_content_only(area, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn replace(&mut self, renderables: Vec<Box<dyn Renderable>>) {
|
||||
self.view.renderables = renderables;
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, renderable: Box<dyn Renderable>) {
|
||||
self.view.renderables.push(renderable);
|
||||
}
|
||||
|
||||
pub(crate) fn pop(&mut self) -> Option<Box<dyn Renderable>> {
|
||||
self.view.renderables.pop()
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.view.renderables.len()
|
||||
}
|
||||
|
||||
pub(crate) fn is_following_bottom(&self) -> bool {
|
||||
self.view.is_scrolled_to_bottom()
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_to_bottom(&mut self) {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderable that caches its desired height.
|
||||
struct CachedRenderable {
|
||||
renderable: Box<dyn Renderable>,
|
||||
@@ -637,7 +708,7 @@ impl TranscriptOverlay {
|
||||
pub(crate) fn sync_live_tail(
|
||||
&mut self,
|
||||
width: u16,
|
||||
active_key: Option<ActiveCellTranscriptKey>,
|
||||
active_key: Option<ActiveCellRenderKey>,
|
||||
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
|
||||
) {
|
||||
let next_key = active_key.map(|key| LiveTailKey {
|
||||
@@ -1095,7 +1166,7 @@ mod tests {
|
||||
})]);
|
||||
overlay.sync_live_tail(
|
||||
/*width*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
Some(ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
@@ -1124,7 +1195,7 @@ mod tests {
|
||||
|
||||
overlay.sync_live_tail(
|
||||
area.width,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
Some(ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
@@ -1147,7 +1218,7 @@ mod tests {
|
||||
})]);
|
||||
|
||||
let calls = std::cell::Cell::new(0usize);
|
||||
let key = ActiveCellTranscriptKey {
|
||||
let key = ActiveCellRenderKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
|
||||
Reference in New Issue
Block a user