refactor transcript view to handle HistoryCells (#3538)

No (intended) functional change.

This refactors the transcript view to hold a list of HistoryCells
instead of a list of Lines. This simplifies and makes much of the logic
more robust, as well as laying the groundwork for future changes, e.g.
live-updating history cells in the transcript.

Similar to #2879 in goal. Fixes #2755.
This commit is contained in:
Jeremy Rose
2025-09-13 19:23:14 -07:00
committed by GitHub
parent bac8a427f3
commit 4891ee29c5
9 changed files with 309 additions and 389 deletions

View File

@@ -144,19 +144,19 @@ impl ConversationManager {
self.conversations.write().await.remove(conversation_id)
}
/// Fork an existing conversation by dropping the last `drop_last_messages`
/// user/assistant messages from its transcript and starting a new
/// Fork an existing conversation by taking messages up to the given position
/// (not including the message at the given position) and starting a new
/// conversation with identical configuration (unless overridden by the
/// caller's `config`). The new conversation will have a fresh id.
pub async fn fork_conversation(
&self,
num_messages_to_drop: usize,
nth_user_message: usize,
config: Config,
path: PathBuf,
) -> CodexResult<NewConversation> {
// Compute the prefix up to the cut point.
let history = RolloutRecorder::get_rollout_history(&path).await?;
let history = truncate_after_dropping_last_messages(history, num_messages_to_drop);
let history = truncate_after_nth_user_message(history, nth_user_message);
// Spawn a new conversation with the computed initial history.
let auth_manager = self.auth_manager.clone();
@@ -169,14 +169,10 @@ impl ConversationManager {
}
}
/// Return a prefix of `items` obtained by dropping the last `n` user messages
/// and all items that follow them.
fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> InitialHistory {
if n == 0 {
return InitialHistory::Forked(history.get_rollout_items());
}
// Work directly on rollout items, and cut the vector at the nth-from-last user message input.
/// Return a prefix of `items` obtained by cutting strictly before the nth user message
/// (0-based) and all items that follow it.
fn truncate_after_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory {
// Work directly on rollout items, and cut the vector at the nth user message input.
let items: Vec<RolloutItem> = history.get_rollout_items();
// Find indices of user message inputs in rollout order.
@@ -189,13 +185,13 @@ fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> I
}
}
// If fewer than n user messages exist, treat as empty.
if user_positions.len() < n {
// If fewer than or equal to n user messages exist, treat as empty (out of range).
if user_positions.len() <= n {
return InitialHistory::New;
}
// Cut strictly before the nth-from-last user message (do not keep the nth itself).
let cut_idx = user_positions[user_positions.len() - n];
// Cut strictly before the nth user message (do not keep the nth itself).
let cut_idx = user_positions[n];
let rolled: Vec<RolloutItem> = items.into_iter().take(cut_idx).collect();
if rolled.is_empty() {
@@ -262,7 +258,7 @@ mod tests {
.cloned()
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_after_dropping_last_messages(InitialHistory::Forked(initial), 1);
let truncated = truncate_after_nth_user_message(InitialHistory::Forked(initial), 1);
let got_items = truncated.get_rollout_items();
let expected_items = vec![
RolloutItem::ResponseItem(items[0].clone()),
@@ -279,7 +275,7 @@ mod tests {
.cloned()
.map(RolloutItem::ResponseItem)
.collect();
let truncated2 = truncate_after_dropping_last_messages(InitialHistory::Forked(initial2), 2);
let truncated2 = truncate_after_nth_user_message(InitialHistory::Forked(initial2), 2);
assert!(matches!(truncated2, InitialHistory::New));
}
}

View File

@@ -104,7 +104,8 @@ async fn fork_conversation_twice_drops_to_first_message() {
items
};
// Compute expected prefixes after each fork by truncating base rollout at nth-from-last user input.
// Compute expected prefixes after each fork by truncating base rollout
// strictly before the nth user input (0-based).
let base_items = read_items(&base_path);
let find_user_input_positions = |items: &[RolloutItem]| -> Vec<usize> {
let mut pos = Vec::new();
@@ -126,11 +127,8 @@ async fn fork_conversation_twice_drops_to_first_message() {
};
let user_inputs = find_user_input_positions(&base_items);
// After dropping last user input (n=1), cut strictly before that input if present, else empty.
let cut1 = user_inputs
.get(user_inputs.len().saturating_sub(1))
.copied()
.unwrap_or(0);
// After cutting at nth user input (n=1 → second user message), cut strictly before that input.
let cut1 = user_inputs.get(1).copied().unwrap_or(0);
let expected_after_first: Vec<RolloutItem> = base_items[..cut1].to_vec();
// After dropping again (n=1 on fork1), compute expected relative to fork1's rollout.
@@ -161,12 +159,12 @@ async fn fork_conversation_twice_drops_to_first_message() {
serde_json::to_value(&expected_after_first).unwrap()
);
// Fork again with n=1 → drops the (new) last user message, leaving only the first.
// Fork again with n=0 → drops the (new) last user message, leaving only the first.
let NewConversation {
conversation: codex_fork2,
..
} = conversation_manager
.fork_conversation(1, config_for_fork.clone(), fork1_path.clone())
.fork_conversation(0, config_for_fork.clone(), fork1_path.clone())
.await
.expect("fork 2");

View File

@@ -3,6 +3,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::history_cell::HistoryCell;
use crate::pager_overlay::Overlay;
use crate::resume_picker::ResumeSelection;
use crate::tui;
@@ -46,7 +47,7 @@ pub(crate) struct App {
pub(crate) file_search: FileSearchManager,
pub(crate) transcript_lines: Vec<Line<'static>>,
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
@@ -131,7 +132,7 @@ impl App {
model_saved_to_global: false,
file_search,
enhanced_keys_supported,
transcript_lines: Vec::new(),
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -213,15 +214,12 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::InsertHistoryCell(cell) => {
let mut cell_transcript = cell.transcript_lines();
if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() {
cell_transcript.insert(0, Line::from(""));
}
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_lines(cell_transcript.clone());
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_lines.extend(cell_transcript.clone());
self.transcript_cells.push(cell.clone());
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
@@ -437,7 +435,7 @@ impl App {
} => {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone()));
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
KeyEvent {
@@ -470,7 +468,7 @@ impl App {
kind: KeyEventKind::Press,
..
} if self.backtrack.primed
&& self.backtrack.count > 0
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty() =>
{
// Delegate to helper for clarity; preserves behavior.
@@ -503,7 +501,6 @@ mod tests {
use crate::file_search::FileSearchManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use ratatui::text::Line;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -525,7 +522,7 @@ mod tests {
model_saved_to_profile: false,
model_saved_to_global: false,
file_search,
transcript_lines: Vec::<Line<'static>>::new(),
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,

View File

@@ -1,7 +1,7 @@
use std::path::PathBuf;
use crate::app::App;
use crate::backtrack_helpers;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
@@ -19,11 +19,11 @@ pub(crate) struct BacktrackState {
pub(crate) primed: bool,
/// Session id of the base conversation to fork from.
pub(crate) base_id: Option<ConversationId>,
/// Current step count (Nth last user message).
pub(crate) count: usize,
/// Index in the transcript of the last user message.
pub(crate) nth_user_message: usize,
/// True when the transcript overlay is showing a backtrack preview.
pub(crate) overlay_preview_active: bool,
/// Pending fork request: (base_id, drop_count, prefill).
/// Pending fork request: (base_id, nth_user_message, prefill).
pub(crate) pending: Option<(ConversationId, usize, String)>,
}
@@ -96,9 +96,9 @@ impl App {
&mut self,
prefill: String,
base_id: ConversationId,
drop_last_messages: usize,
nth_user_message: usize,
) {
self.backtrack.pending = Some((base_id, drop_last_messages, prefill));
self.backtrack.pending = Some((base_id, nth_user_message, prefill));
self.app_event_tx.send(crate::app_event::AppEvent::CodexOp(
codex_core::protocol::Op::GetPath,
));
@@ -107,7 +107,7 @@ impl App {
/// Open transcript overlay (enters alternate screen and shows full transcript).
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone()));
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
@@ -130,15 +130,17 @@ impl App {
/// Re-render the full transcript into the terminal scrollback in one call.
/// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_lines.is_empty() {
tui.insert_history_lines(self.transcript_lines.clone());
if !self.transcript_cells.is_empty() {
for cell in &self.transcript_cells {
tui.insert_history_lines(cell.transcript_lines());
}
}
}
/// Initialize backtrack state and show composer hint.
fn prime_backtrack(&mut self) {
self.backtrack.primed = true;
self.backtrack.count = 0;
self.backtrack.nth_user_message = usize::MAX;
self.backtrack.base_id = self.chat_widget.conversation_id();
self.chat_widget.show_esc_backtrack_hint();
}
@@ -157,51 +159,44 @@ impl App {
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.conversation_id();
self.backtrack.overlay_preview_active = true;
let sel = self.compute_backtrack_selection(tui, 1);
self.apply_backtrack_selection(sel);
let last_user_cell_position = self
.transcript_cells
.iter()
.filter_map(|c| c.as_any().downcast_ref::<UserHistoryCell>())
.count() as i64
- 1;
if last_user_cell_position >= 0 {
self.apply_backtrack_selection(last_user_cell_position as usize);
}
tui.frame_requester().schedule_frame();
}
/// Step selection to the next older user message and update overlay.
fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let next = self.backtrack.count.saturating_add(1);
let sel = self.compute_backtrack_selection(tui, next);
self.apply_backtrack_selection(sel);
let last_user_cell_position = self
.transcript_cells
.iter()
.filter(|c| c.as_any().is::<UserHistoryCell>())
.take(self.backtrack.nth_user_message)
.count()
.saturating_sub(1);
self.apply_backtrack_selection(last_user_cell_position);
tui.frame_requester().schedule_frame();
}
/// Compute normalized target, scroll offset, and highlight for requested step.
fn compute_backtrack_selection(
&self,
tui: &tui::Tui,
requested_n: usize,
) -> (usize, Option<usize>, Option<(usize, usize)>) {
let nth = backtrack_helpers::normalize_backtrack_n(&self.transcript_lines, requested_n);
let header_idx =
backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, nth);
let offset = header_idx.map(|idx| {
backtrack_helpers::wrapped_offset_before(
&self.transcript_lines,
idx,
tui.terminal.viewport_area.width,
)
});
let hl = backtrack_helpers::highlight_range_for_nth_last_user(&self.transcript_lines, nth);
(nth, offset, hl)
}
/// Apply a computed backtrack selection to the overlay and internal counter.
fn apply_backtrack_selection(
&mut self,
selection: (usize, Option<usize>, Option<(usize, usize)>),
) {
let (nth, offset, hl) = selection;
self.backtrack.count = nth;
fn apply_backtrack_selection(&mut self, nth_user_message: usize) {
self.backtrack.nth_user_message = nth_user_message;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
if let Some(off) = offset {
t.set_scroll_offset(off);
let cell = self
.transcript_cells
.iter()
.enumerate()
.filter(|(_, c)| c.as_any().is::<UserHistoryCell>())
.nth(nth_user_message);
if let Some((idx, _)) = cell {
t.set_highlight_cell(Some(idx));
}
t.set_highlight_range(hl);
}
}
@@ -219,13 +214,19 @@ impl App {
/// Handle Enter in overlay backtrack preview: confirm selection and reset state.
fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {
let nth_user_message = self.backtrack.nth_user_message;
if let Some(base_id) = self.backtrack.base_id {
let drop_last_messages = self.backtrack.count;
let prefill =
backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages)
.unwrap_or_default();
let user_cells = self
.transcript_cells
.iter()
.filter_map(|c| c.as_any().downcast_ref::<UserHistoryCell>())
.collect::<Vec<_>>();
let prefill = user_cells
.get(nth_user_message)
.map(|c| c.message.clone())
.unwrap_or_default();
self.close_transcript_overlay(tui);
self.request_backtrack(prefill, base_id, drop_last_messages);
self.request_backtrack(prefill, base_id, nth_user_message);
}
self.reset_backtrack_state();
}
@@ -244,11 +245,15 @@ impl App {
/// Computes the prefill from the selected user message and requests history.
pub(crate) fn confirm_backtrack_from_main(&mut self) {
if let Some(base_id) = self.backtrack.base_id {
let drop_last_messages = self.backtrack.count;
let prefill =
backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages)
.unwrap_or_default();
self.request_backtrack(prefill, base_id, drop_last_messages);
let prefill = self
.transcript_cells
.iter()
.filter(|c| c.as_any().is::<UserHistoryCell>())
.nth(self.backtrack.nth_user_message)
.and_then(|c| c.as_any().downcast_ref::<UserHistoryCell>())
.map(|c| c.message.clone())
.unwrap_or_default();
self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message);
}
self.reset_backtrack_state();
}
@@ -257,7 +262,7 @@ impl App {
pub(crate) fn reset_backtrack_state(&mut self) {
self.backtrack.primed = false;
self.backtrack.base_id = None;
self.backtrack.count = 0;
self.backtrack.nth_user_message = usize::MAX;
// In case a hint is somehow still visible (e.g., race with overlay open/close).
self.chat_widget.clear_esc_backtrack_hint();
}
@@ -271,9 +276,9 @@ impl App {
) -> Result<()> {
if let Some((base_id, _, _)) = self.backtrack.pending.as_ref()
&& ev.conversation_id == *base_id
&& let Some((_, drop_count, prefill)) = self.backtrack.pending.take()
&& let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take()
{
self.fork_and_switch_to_new_conversation(tui, ev, drop_count, prefill)
self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill)
.await;
}
Ok(())
@@ -284,17 +289,17 @@ impl App {
&mut self,
tui: &mut tui::Tui,
ev: ConversationPathResponseEvent,
drop_count: usize,
nth_user_message: usize,
prefill: String,
) {
let cfg = self.chat_widget.config_ref().clone();
// Perform the fork via a thin wrapper for clarity/testability.
let result = self
.perform_fork(ev.path.clone(), drop_count, cfg.clone())
.perform_fork(ev.path.clone(), nth_user_message, cfg.clone())
.await;
match result {
Ok(new_conv) => {
self.install_forked_conversation(tui, cfg, new_conv, drop_count, &prefill)
self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill)
}
Err(e) => tracing::error!("error forking conversation: {e:#}"),
}
@@ -304,10 +309,12 @@ impl App {
async fn perform_fork(
&self,
path: PathBuf,
drop_count: usize,
nth_user_message: usize,
cfg: codex_core::config::Config,
) -> codex_core::error::Result<codex_core::NewConversation> {
self.server.fork_conversation(drop_count, cfg, path).await
self.server
.fork_conversation(nth_user_message, cfg, path)
.await
}
/// Install a forked conversation into the ChatWidget and update UI to reflect selection.
@@ -316,7 +323,7 @@ impl App {
tui: &mut tui::Tui,
cfg: codex_core::config::Config,
new_conv: codex_core::NewConversation,
drop_count: usize,
nth_user_message: usize,
prefill: &str,
) {
let conv = new_conv.conversation;
@@ -332,7 +339,7 @@ impl App {
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
// Trim transcript up to the selected user message and re-render it.
self.trim_transcript_for_backtrack(drop_count);
self.trim_transcript_for_backtrack(nth_user_message);
self.render_transcript_once(tui);
if !prefill.is_empty() {
self.chat_widget.set_composer_text(prefill.to_string());
@@ -340,14 +347,21 @@ impl App {
tui.frame_requester().schedule_frame();
}
/// Trim transcript_lines to preserve only content up to the selected user message.
fn trim_transcript_for_backtrack(&mut self, drop_count: usize) {
if let Some(cut_idx) =
backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, drop_count)
{
self.transcript_lines.truncate(cut_idx);
} else {
self.transcript_lines.clear();
}
/// Trim transcript_cells to preserve only content up to the selected user message.
fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) {
let cut_idx = self
.transcript_cells
.iter()
.enumerate()
.filter_map(|(idx, cell)| {
if cell.as_any().is::<UserHistoryCell>() {
Some(idx)
} else {
None
}
})
.nth(nth_user_message - 1)
.unwrap_or(self.transcript_cells.len());
self.transcript_cells.truncate(cut_idx);
}
}

View File

@@ -1,153 +0,0 @@
use ratatui::text::Line;
/// Convenience: compute the highlight range for the Nth last user message.
pub(crate) fn highlight_range_for_nth_last_user(
lines: &[Line<'_>],
n: usize,
) -> Option<(usize, usize)> {
let header = find_nth_last_user_header_index(lines, n)?;
Some(highlight_range_from_header(lines, header))
}
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
let before = &lines[0..header_idx];
crate::wrapping::word_wrap_lines(before, width as usize).len()
}
/// Find the header index for the Nth last user message in the transcript.
/// Returns `None` if `n == 0` or there are fewer than `n` user messages.
pub(crate) fn find_nth_last_user_header_index(lines: &[Line<'_>], n: usize) -> Option<usize> {
if n == 0 {
return None;
}
let mut found = 0usize;
for (idx, line) in lines.iter().enumerate().rev() {
let content: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
if content.trim() == "user" {
found += 1;
if found == n {
return Some(idx);
}
}
}
None
}
/// Normalize a requested backtrack step `n` against the available user messages.
/// - Returns `0` if there are no user messages.
/// - Returns `n` if the Nth last user message exists.
/// - Otherwise wraps to `1` (the most recent user message).
pub(crate) fn normalize_backtrack_n(lines: &[Line<'_>], n: usize) -> usize {
if n == 0 {
return 0;
}
if find_nth_last_user_header_index(lines, n).is_some() {
return n;
}
if find_nth_last_user_header_index(lines, 1).is_some() {
1
} else {
0
}
}
/// Extract the text content of the Nth last user message.
/// The message body is considered to be the lines following the "user" header
/// until the first blank line.
pub(crate) fn nth_last_user_text(lines: &[Line<'_>], n: usize) -> Option<String> {
let header_idx = find_nth_last_user_header_index(lines, n)?;
extract_message_text_after_header(lines, header_idx)
}
/// Extract message text starting after `header_idx` until the first blank line.
fn extract_message_text_after_header(lines: &[Line<'_>], header_idx: usize) -> Option<String> {
let start = header_idx + 1;
let mut out: Vec<String> = Vec::new();
for line in lines.iter().skip(start) {
let is_blank = line
.spans
.iter()
.all(|s| s.content.as_ref().trim().is_empty());
if is_blank {
break;
}
let text: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
out.push(text);
}
if out.is_empty() {
None
} else {
Some(out.join("\n"))
}
}
/// Given a header index, return the inclusive range for the message block
/// [header_idx, end) where end is the first blank line after the header or the
/// end of the transcript.
fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, usize) {
let mut end = header_idx + 1;
while end < lines.len() {
let is_blank = lines[end]
.spans
.iter()
.all(|s| s.content.as_ref().trim().is_empty());
if is_blank {
break;
}
end += 1;
}
(header_idx, end)
}
#[cfg(test)]
mod tests {
use super::*;
fn line(s: &str) -> Line<'static> {
s.to_string().into()
}
fn transcript_with_users(count: usize) -> Vec<Line<'static>> {
// Build a transcript with `count` user messages, each followed by one body line and a blank line.
let mut v = Vec::new();
for i in 0..count {
v.push(line("user"));
v.push(line(&format!("message {i}")));
v.push(line(""));
}
v
}
#[test]
fn normalize_wraps_to_one_when_past_oldest() {
let lines = transcript_with_users(2);
assert_eq!(normalize_backtrack_n(&lines, 1), 1);
assert_eq!(normalize_backtrack_n(&lines, 2), 2);
// Requesting 3rd when only 2 exist wraps to 1
assert_eq!(normalize_backtrack_n(&lines, 3), 1);
}
#[test]
fn normalize_returns_zero_when_no_user_messages() {
let lines = transcript_with_users(0);
assert_eq!(normalize_backtrack_n(&lines, 1), 0);
assert_eq!(normalize_backtrack_n(&lines, 5), 0);
}
#[test]
fn normalize_keeps_valid_n() {
let lines = transcript_with_users(3);
assert_eq!(normalize_backtrack_n(&lines, 2), 2);
}
}

View File

@@ -43,6 +43,7 @@ use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
@@ -69,7 +70,7 @@ pub(crate) enum PatchEventType {
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync {
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
fn transcript_lines(&self) -> Vec<Line<'static>> {
@@ -89,9 +90,15 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync {
}
}
impl dyn HistoryCell {
pub(crate) fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Debug)]
pub(crate) struct UserHistoryCell {
message: String,
pub message: String,
}
impl HistoryCell for UserHistoryCell {

View File

@@ -31,7 +31,6 @@ mod app;
mod app_backtrack;
mod app_event;
mod app_event_sender;
mod backtrack_helpers;
mod bottom_pane;
mod chatwidget;
mod citation_regex;

View File

@@ -1,6 +1,8 @@
use std::io::Result;
use std::sync::Arc;
use std::time::Duration;
use crate::history_cell::HistoryCell;
use crate::render::line_utils::push_owned_lines;
use crate::tui;
use crate::tui::TuiEvent;
@@ -15,6 +17,7 @@ use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
@@ -24,8 +27,8 @@ pub(crate) enum Overlay {
}
impl Overlay {
pub(crate) fn new_transcript(lines: Vec<Line<'static>>) -> Self {
Self::Transcript(TranscriptOverlay::new(lines))
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self::Transcript(TranscriptOverlay::new(cells))
}
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
@@ -73,21 +76,24 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
/// Generic widget for rendering a pager view.
struct PagerView {
lines: Vec<Line<'static>>,
texts: Vec<Text<'static>>,
scroll_offset: usize,
title: String,
wrap_cache: Option<WrapCache>,
last_content_height: Option<usize>,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
}
impl PagerView {
fn new(lines: Vec<Line<'static>>, title: String, scroll_offset: usize) -> Self {
fn new(texts: Vec<Text<'static>>, title: String, scroll_offset: usize) -> Self {
Self {
lines,
texts,
scroll_offset,
title,
wrap_cache: None,
last_content_height: None,
pending_scroll_chunk: None,
}
}
@@ -96,6 +102,14 @@ impl PagerView {
let content_area = self.scroll_area(area);
self.update_last_content_height(content_area.height);
self.ensure_wrapped(content_area.width);
// If there is a pending request to scroll a specific chunk into view,
// satisfy it now that wrapping is up to date for this width.
if let (Some(idx), Some(cache)) =
(self.pending_scroll_chunk.take(), self.wrap_cache.as_ref())
&& let Some(range) = cache.chunk_ranges.get(idx).cloned()
{
self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len());
}
// Compute page bounds without holding an immutable borrow on cache while mutating self
let wrapped_len = self
.wrap_cache
@@ -108,40 +122,12 @@ impl PagerView {
let start = self.scroll_offset;
let end = (start + content_area.height as usize).min(wrapped_len);
let (wrapped, _src_idx) = self.cached();
let wrapped = self.cached();
let page = &wrapped[start..end];
self.render_content_page_prepared(content_area, buf, page);
self.render_bottom_bar(area, content_area, buf, wrapped);
}
fn render_with_highlight(
&mut self,
area: Rect,
buf: &mut Buffer,
highlight: Option<(usize, usize)>,
) {
self.render_header(area, buf);
let content_area = self.scroll_area(area);
self.update_last_content_height(content_area.height);
self.ensure_wrapped(content_area.width);
// Compute page bounds first to avoid borrow conflicts
let wrapped_len = self
.wrap_cache
.as_ref()
.map(|c| c.wrapped.len())
.unwrap_or(0);
self.scroll_offset = self
.scroll_offset
.min(wrapped_len.saturating_sub(content_area.height as usize));
let start = self.scroll_offset;
let end = (start + content_area.height as usize).min(wrapped_len);
let (wrapped, src_idx) = self.cached();
let page = self.page_with_optional_highlight(wrapped, src_idx, start, end, highlight);
self.render_content_page_prepared(content_area, buf, &page);
self.render_bottom_bar(area, content_area, buf, wrapped);
}
fn render_header(&self, area: Rect, buf: &mut Buffer) {
Span::from("/ ".repeat(area.width as usize / 2))
.dim()
@@ -270,7 +256,8 @@ impl PagerView {
struct WrapCache {
width: u16,
wrapped: Vec<Line<'static>>,
src_idx: Vec<usize>,
/// For each input Text chunk, the inclusive-excluded range of wrapped lines produced.
chunk_ranges: Vec<std::ops::Range<usize>>,
base_len: usize,
}
@@ -278,74 +265,39 @@ impl PagerView {
fn ensure_wrapped(&mut self, width: u16) {
let width = width.max(1);
let needs = match self.wrap_cache {
Some(ref c) => c.width != width || c.base_len != self.lines.len(),
Some(ref c) => c.width != width || c.base_len != self.texts.len(),
None => true,
};
if !needs {
return;
}
let mut wrapped: Vec<Line<'static>> = Vec::new();
let mut src_idx: Vec<usize> = Vec::new();
for (i, line) in self.lines.iter().enumerate() {
let ws = crate::wrapping::word_wrap_line(line, width as usize);
src_idx.extend(std::iter::repeat_n(i, ws.len()));
push_owned_lines(&ws, &mut wrapped);
let mut chunk_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(self.texts.len());
for text in &self.texts {
let start = wrapped.len();
for line in &text.lines {
let ws = crate::wrapping::word_wrap_line(line, width as usize);
push_owned_lines(&ws, &mut wrapped);
}
let end = wrapped.len();
chunk_ranges.push(start..end);
}
self.wrap_cache = Some(WrapCache {
width,
wrapped,
src_idx,
base_len: self.lines.len(),
chunk_ranges,
base_len: self.texts.len(),
});
}
fn cached(&self) -> (&[Line<'static>], &[usize]) {
fn cached(&self) -> &[Line<'static>] {
if let Some(cache) = self.wrap_cache.as_ref() {
(&cache.wrapped, &cache.src_idx)
&cache.wrapped
} else {
(&[], &[])
&[]
}
}
fn page_with_optional_highlight<'a>(
&self,
wrapped: &'a [Line<'static>],
src_idx: &[usize],
start: usize,
end: usize,
highlight: Option<(usize, usize)>,
) -> std::borrow::Cow<'a, [Line<'static>]> {
use ratatui::style::Modifier;
let (hi_start, hi_end) = match highlight {
Some(r) => r,
None => return std::borrow::Cow::Borrowed(&wrapped[start..end]),
};
let mut out: Vec<Line<'static>> = Vec::with_capacity(end - start);
let mut bold_done = false;
for (row, src_line) in wrapped
.iter()
.enumerate()
.skip(start)
.take(end.saturating_sub(start))
{
let mut line = src_line.clone();
if let Some(src) = src_idx.get(row).copied()
&& src >= hi_start
&& src < hi_end
{
for (i, s) in line.spans.iter_mut().enumerate() {
s.style.add_modifier |= Modifier::REVERSED;
if !bold_done && i == 0 {
s.style.add_modifier |= Modifier::BOLD;
bold_done = true;
}
}
}
out.push(line);
}
std::borrow::Cow::Owned(out)
}
fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX {
return true;
@@ -363,38 +315,108 @@ impl PagerView {
let max_scroll = cache.wrapped.len().saturating_sub(visible);
self.scroll_offset >= max_scroll
}
/// Request that the given text chunk index be scrolled into view on next render.
fn scroll_chunk_into_view(&mut self, chunk_index: usize) {
self.pending_scroll_chunk = Some(chunk_index);
}
fn ensure_range_visible(
&mut self,
range: std::ops::Range<usize>,
viewport_height: usize,
total_wrapped: usize,
) {
if viewport_height == 0 || total_wrapped == 0 {
return;
}
let first = range.start.min(total_wrapped.saturating_sub(1));
let last = range
.end
.saturating_sub(1)
.min(total_wrapped.saturating_sub(1));
let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1));
let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1));
if first < current_top {
self.scroll_offset = first;
} else if last > current_bottom {
// Scroll just enough so that 'last' is visible at the bottom
self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1));
}
}
}
pub(crate) struct TranscriptOverlay {
view: PagerView,
highlight_range: Option<(usize, usize)>,
cells: Vec<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
is_done: bool,
}
impl TranscriptOverlay {
pub(crate) fn new(transcript_lines: Vec<Line<'static>>) -> Self {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self {
view: PagerView::new(
transcript_lines,
Self::render_cells_to_texts(&transcript_cells, None),
"T R A N S C R I P T".to_string(),
usize::MAX,
),
highlight_range: None,
cells: transcript_cells,
highlight_cell: None,
is_done: false,
}
}
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
fn render_cells_to_texts(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
) -> Vec<Text<'static>> {
let mut texts: Vec<Text<'static>> = Vec::new();
let mut first = true;
for (idx, cell) in cells.iter().enumerate() {
let mut lines: Vec<Line<'static>> = Vec::new();
if !cell.is_stream_continuation() && !first {
lines.push(Line::from(""));
}
let cell_lines = if Some(idx) == highlight_cell {
cell.transcript_lines()
.into_iter()
.map(|l| l.reversed())
.collect()
} else {
cell.transcript_lines()
};
lines.extend(cell_lines);
texts.push(Text::from(lines));
first = false;
}
texts
}
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
self.view.lines.extend(lines);
// Append as a new Text chunk (with a separating blank if needed)
let mut lines: Vec<Line<'static>> = Vec::new();
if !cell.is_stream_continuation() && !self.cells.is_empty() {
lines.push(Line::from(""));
}
lines.extend(cell.transcript_lines());
self.view.texts.push(Text::from(lines));
self.cells.push(cell);
self.view.wrap_cache = None;
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
self.highlight_range = range;
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.view.wrap_cache = None;
self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
@@ -402,9 +424,7 @@ impl TranscriptOverlay {
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")];
if let Some((start, end)) = self.highlight_range
&& end > start
{
if self.highlight_cell.is_some() {
pairs.push(("", "edit message"));
}
render_key_hints(line2, buf, &pairs);
@@ -414,8 +434,7 @@ impl TranscriptOverlay {
let top_h = area.height.saturating_sub(3);
let top = Rect::new(area.x, area.y, area.width, top_h);
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
self.view
.render_with_highlight(top, buf, self.highlight_range);
self.view.render(top, buf);
self.render_hints(bottom, buf);
}
}
@@ -458,9 +477,6 @@ impl TranscriptOverlay {
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
pub(crate) fn set_scroll_offset(&mut self, offset: usize) {
self.view.scroll_offset = offset;
}
}
pub(crate) struct StaticOverlay {
@@ -471,7 +487,7 @@ pub(crate) struct StaticOverlay {
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self {
view: PagerView::new(lines, title, 0),
view: PagerView::new(vec![Text::from(lines)], title, 0),
is_done: false,
}
}
@@ -534,9 +550,26 @@ mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[derive(Debug)]
struct TestCell {
lines: Vec<Line<'static>>,
}
impl crate::history_cell::HistoryCell for TestCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
}
#[test]
fn edit_prev_hint_is_visible() {
let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]);
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
lines: vec![Line::from("hello")],
})]);
// Render into a small buffer and assert the backtrack hint is present
let area = Rect::new(0, 0, 40, 10);
@@ -561,9 +594,15 @@ mod tests {
fn transcript_overlay_snapshot_basic() {
// Prepare a transcript overlay with a few lines
let mut overlay = TranscriptOverlay::new(vec![
Line::from("alpha"),
Line::from("beta"),
Line::from("gamma"),
Arc::new(TestCell {
lines: vec![Line::from("alpha")],
}),
Arc::new(TestCell {
lines: vec![Line::from("beta")],
}),
Arc::new(TestCell {
lines: vec![Line::from("gamma")],
}),
]);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
@@ -573,8 +612,15 @@ mod tests {
#[test]
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
let mut overlay =
TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect());
let mut overlay = TranscriptOverlay::new(
(0..20)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
@@ -584,22 +630,33 @@ mod tests {
"expected initial render to leave view at bottom"
);
overlay.insert_lines(vec!["tail".into()]);
overlay.insert_cell(Arc::new(TestCell {
lines: vec!["tail".into()],
}));
assert_eq!(overlay.view.scroll_offset, usize::MAX);
}
#[test]
fn transcript_overlay_preserves_manual_scroll_position() {
let mut overlay =
TranscriptOverlay::new((0..20).map(|i| Line::from(format!("line{i}"))).collect());
let mut overlay = TranscriptOverlay::new(
(0..20)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
overlay.view.scroll_offset = 0;
overlay.insert_lines(vec!["tail".into()]);
overlay.insert_cell(Arc::new(TestCell {
lines: vec!["tail".into()],
}));
assert_eq!(overlay.view.scroll_offset, 0);
}
@@ -620,17 +677,21 @@ mod tests {
#[test]
fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() {
let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output.";
let mut pv = PagerView::new(vec![long.into(), long.into()], "T".to_string(), 0);
let mut pv = PagerView::new(
vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])],
"T".to_string(),
0,
);
// Build cache at width 24
pv.ensure_wrapped(24);
let (w1, _) = pv.cached();
let w1 = pv.cached();
assert!(!w1.is_empty(), "expected wrapped output to be non-empty");
let ptr1 = w1.as_ptr();
// Re-run with same width: cache should be reused (pointer stability heuristic)
pv.ensure_wrapped(24);
let (w2, _) = pv.cached();
let w2 = pv.cached();
let ptr2 = w2.as_ptr();
assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width");
@@ -638,7 +699,7 @@ mod tests {
// Drop immutable borrow before mutating
let prev_len = w2.len();
pv.ensure_wrapped(36);
let (w3, _) = pv.cached();
let w3 = pv.cached();
assert_ne!(
prev_len,
w3.len(),
@@ -649,15 +710,16 @@ mod tests {
#[test]
fn pager_wrap_cache_invalidates_on_append() {
let long = "Another long line for wrapping behavior verification.";
let mut pv = PagerView::new(vec![long.into()], "T".to_string(), 0);
let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0);
pv.ensure_wrapped(28);
let (w1, _) = pv.cached();
let w1 = pv.cached();
let len1 = w1.len();
// Append new lines should cause ensure_wrapped to rebuild due to len change
pv.lines.extend([long.into(), long.into()]);
pv.texts.push(Text::from(vec![long.into()]));
pv.texts.push(Text::from(vec![long.into()]));
pv.ensure_wrapped(28);
let (w2, _) = pv.cached();
let w2 = pv.cached();
assert!(
w2.len() >= len1,
"wrapped length should grow or stay same after append"

View File

@@ -4,10 +4,10 @@ expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
"alpha "
" "
"beta "
" "
"gamma "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ scroll PgUp/PgDn page Home/End "
" q quit Esc edit prev "