Paginate TUI transcript history (#36950)

## What changed

- Load older transcript items in bounded pages as users scroll upward, and let `Home` continue fetching until the start of the thread.
- Show loading, partial-history, retry, and complete states in the transcript overlay while preserving the visible scroll position as pages are prepended.
- Use paginated history for resume, fork, retry, and prompt-edit flows, while falling back to legacy history requests when the app server does not support pagination.

## Testing

- Cover multi-page transcript loading, page-boundary prompt reconciliation, legacy-server fallback, bounded retry history, and transcript history-state rendering.

GitOrigin-RevId: cc6b764bc4185d079b2f68bedf235c1d08278dfb
This commit is contained in:
Felipe Coury
2026-08-04 19:12:46 +00:00
committed by copyberry
parent 3b8d22ec2c
commit dbcd837c20
16 changed files with 1019 additions and 169 deletions
+2
View File
@@ -535,6 +535,7 @@ pub(crate) struct App {
has_emitted_history_lines: bool,
transcript_reflow: TranscriptReflowState,
initial_history_replay_buffer: Option<InitialHistoryReplayBuffer>,
pub(crate) scrollback_has_older_history: bool,
pub(crate) enhanced_keys_supported: bool,
pub(crate) keymap: RuntimeKeymap,
@@ -1057,6 +1058,7 @@ See the Codex keymap documentation for supported actions and examples."
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
+22 -8
View File
@@ -9,6 +9,7 @@ use super::*;
use crate::app_server_session::ForkGoalContinuation;
use crate::config_update::format_config_error;
use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome;
use crate::pager_overlay::TranscriptHistoryState;
#[cfg(target_os = "windows")]
use codex_config::types::WindowsSandboxModeToml;
@@ -43,6 +44,12 @@ impl App {
.await
{
app_server.cancel_older_history_page(thread_id);
if self.chat_widget.thread_id() == Some(thread_id)
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
{
overlay.set_history_state(TranscriptHistoryState::Failed);
tui.frame_requester().schedule_frame();
}
tracing::warn!(%thread_id, error = %err, "failed to load older transcript history");
}
}
@@ -282,12 +289,13 @@ impl App {
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
let config = self.fresh_session_config();
let started = match app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
{
Ok(thread) => match crate::app_backtrack::backtrack_fork_before_turn_id(
&thread.turns,
let turns = match self.thread_event_channels.get(&thread_id) {
Some(channel) => Some(channel.store.lock().await.turns.clone()),
None => None,
};
let started = match turns {
Some(turns) => match crate::app_backtrack::backtrack_fork_before_turn_id(
&turns,
nth_user_message,
&mut prompt,
) {
@@ -296,7 +304,7 @@ impl App {
|| app_server.has_older_history(thread_id) =>
{
let before_turn_id = before_turn_id
.or_else(|| thread.turns.first().map(|turn| turn.id.clone()));
.or_else(|| turns.first().map(|turn| turn.id.clone()));
app_server
.fork_thread_at(
config.clone(),
@@ -316,7 +324,9 @@ impl App {
}
Err(err) => Err(err),
},
Err(err) => Err(err),
None => Err(color_eyre::eyre::eyre!(
"the selected thread is no longer available for prompt editing"
)),
};
match started {
Ok(forked) => {
@@ -352,6 +362,10 @@ impl App {
self.insert_history_cell(tui, cell);
}
AppEvent::EndInitialHistoryReplayBuffer => {
self.scrollback_has_older_history = self
.chat_widget
.thread_id()
.is_some_and(|thread_id| app_server.has_older_history(thread_id));
self.finish_initial_history_replay_buffer(tui);
}
AppEvent::ConsolidateAgentMessage {
@@ -3,12 +3,56 @@
use std::collections::HashSet;
use super::*;
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
use crate::app_server_session::thread_items_page_params;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::TranscriptHistoryState;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_items_to_transcript_cells;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ThreadItemsListResponse;
impl App {
/// Start one bounded page request shared by transcript-history navigation.
pub(crate) fn request_older_history_page(
&self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> bool {
let Some(cursor) = app_server.begin_older_history_page(thread_id) else {
return false;
};
tracing::debug!(
%thread_id,
%cursor,
overlay = self.overlay.is_some(),
"loading older transcript history page"
);
let request_id = app_server.next_request_id();
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = request_handle
.request_typed::<ThreadItemsListResponse>(ClientRequest::ThreadItemsList {
request_id,
params: thread_items_page_params(
thread_id,
/*turn_id*/ None,
Some(cursor.clone()),
HISTORY_ITEM_PAGE_LIMIT,
),
})
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::OlderThreadHistoryLoaded {
thread_id,
cursor,
result,
});
});
true
}
pub(super) async fn handle_older_history_page(
&mut self,
tui: &mut tui::Tui,
@@ -173,9 +217,24 @@ impl App {
.count(),
);
}
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
let mut continue_to_start = false;
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
let index = overlay.prepend(cells.clone(), width);
self.transcript_cells.splice(index..index, cells);
let previous_state = overlay.set_history_state(if self.scrollback_has_older_history {
TranscriptHistoryState::Partial
} else {
TranscriptHistoryState::Complete
});
continue_to_start = previous_state == TranscriptHistoryState::LoadingBeginning
&& self.scrollback_has_older_history;
}
if continue_to_start
&& self.request_older_history_page(app_server, thread_id)
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
{
overlay.set_history_state(TranscriptHistoryState::LoadingBeginning);
}
if self.backtrack.overlay_preview_active {
self.apply_backtrack_selection_internal(self.backtrack.nth_user_message);
+1
View File
@@ -171,6 +171,7 @@ impl App {
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget.clear_pending_rate_limit_reset_hint();
self.initial_history_replay_buffer = None;
self.scrollback_has_older_history = false;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
self.skill_load_warnings.clear();
+5 -7
View File
@@ -254,13 +254,11 @@ impl App {
}
if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
self.scrollback_has_older_history = self
.chat_widget
.thread_id()
.is_some_and(|thread_id| app_server.has_older_history(thread_id));
self.open_transcript_overlay(tui);
return;
}
+49 -3
View File
@@ -3,9 +3,12 @@
use super::session_lifecycle::ThreadAttachPresentation;
use super::*;
use crate::app_server_session::ForkGoalContinuation;
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
use crate::chatwidget::ThreadInputState;
use crate::chatwidget::ThreadInputStateRestoreMode;
use crate::chatwidget::UserMessage;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::UserInput;
pub(super) struct SafetyBufferedRetry {
@@ -76,9 +79,52 @@ impl App {
return;
}
let thread = match app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
let thread = match async {
let mut thread = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await?;
if thread.history_mode == ThreadHistoryMode::Legacy {
app_server
.hydrate_initial_thread_history(
&mut thread,
/*turn_cursor*/ None,
/*item_cursor*/ None,
/*config*/ None,
crate::app_server_session::HistoryHydrationScope::Initial,
)
.await?;
} else {
let page = app_server
.thread_turns_page(thread_id, /*cursor*/ None)
.await?;
thread.turns = page.data.into_iter().rev().collect();
if let Some(turn_index) = thread.turns.iter().position(|turn| turn.id == turn_id) {
let page = app_server
.thread_items_page(
thread_id,
Some(&turn_id),
/*cursor*/ None,
HISTORY_ITEM_PAGE_LIMIT,
)
.await?;
if page.next_cursor.is_some() {
color_eyre::eyre::bail!(
"Cannot safely retry a turn whose input exceeds the bounded history page."
);
}
let turn = &mut thread.turns[turn_index];
turn.items = page
.data
.into_iter()
.rev()
.map(|entry| entry.item)
.collect();
turn.items_view = TurnItemsView::Full;
}
}
Ok::<_, color_eyre::Report>(thread)
}
.await
{
Ok(thread) => thread,
Err(err) => {
@@ -31,3 +31,14 @@ fn retry_rejects_a_stale_or_in_progress_turn() {
assert!(safety_retry_fork_point(&in_progress, "missing").is_err());
assert!(safety_retry_fork_point(&previous_in_progress, "turn-2").is_err());
}
#[test]
fn retry_accepts_a_targeted_latest_turn_and_its_completed_predecessor() {
let turns = vec![
turn("turn-1", TurnStatus::Completed),
turn("turn-2", TurnStatus::Interrupted),
];
assert!(safety_retry_fork_point(&turns, "turn-2").is_ok());
assert!(safety_retry_fork_point(&turns[1..], "turn-2").is_ok());
}
+15 -13
View File
@@ -384,22 +384,24 @@ impl App {
error = %resume_err,
"failed to resume live thread for selection; falling back to thread/read"
);
let (thread, turns) = match app_server
.thread_read(thread_id, /*include_turns*/ true)
let mut thread = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await?;
match app_server
.hydrate_initial_thread_history(
&mut thread,
/*turn_cursor*/ None,
/*item_cursor*/ None,
Some(&self.config),
crate::app_server_session::HistoryHydrationScope::Initial,
)
.await
{
Ok(thread) => {
let turns = thread.turns.clone();
(thread, turns)
}
Err(err) if Self::can_fallback_from_include_turns_error(&err) => {
let thread = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await?;
(thread, Vec::new())
}
Ok(()) => {}
Err(err) if Self::can_fallback_from_include_turns_error(&err) => {}
Err(err) => return Err(err),
};
}
let turns = thread.turns.clone();
if turns.is_empty() {
// A `thread/read` fallback without turns would create a blank local replay
// channel with no live listener attached, which blocks later real re-attach.
+1
View File
@@ -38,6 +38,7 @@ pub(super) async fn make_test_app() -> App {
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
+2
View File
@@ -4740,6 +4740,7 @@ async fn make_test_app() -> App {
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
@@ -4809,6 +4810,7 @@ async fn make_test_app_with_channels() -> (
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
@@ -8,6 +8,7 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadItemsListParams;
@@ -34,17 +35,27 @@ use tokio::task::JoinHandle;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
type RecordedRequests = Arc<Mutex<Vec<JSONRPCRequest>>>;
type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle<Result<()>>);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HistoryCapabilities {
Current,
LegacyOnly,
LegacyOnlyUnsupportedVariant,
}
/// Returns and resets `(thread/loaded/list, thread/read)` request counts.
fn take_backfill_counts(requests: &Arc<Mutex<Vec<String>>>) -> (usize, usize) {
fn take_backfill_counts(requests: &RecordedRequests) -> (usize, usize) {
let requests = std::mem::take(&mut *requests.lock().expect("request recorder lock"));
(
requests
.iter()
.filter(|method| *method == "thread/loaded/list")
.filter(|request| request.method == "thread/loaded/list")
.count(),
requests
.iter()
.filter(|method| *method == "thread/read")
.filter(|request| request.method == "thread/read")
.count(),
)
}
@@ -52,13 +63,25 @@ fn take_backfill_counts(requests: &Arc<Mutex<Vec<String>>>) -> (usize, usize) {
/// Starts an embedded app server behind a loopback WebSocket proxy that records JSON-RPC methods.
async fn start_recording_app_server(
config: &Config,
blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
failed_thread_name: Option<&'static str>,
) -> Result<RecordingAppServer> {
start_recording_app_server_with_history(
config,
HistoryCapabilities::Current,
blocked_thread_list,
failed_thread_name,
)
.await
}
/// Proxies a real app server while optionally rejecting modern pagination like an older server.
async fn start_recording_app_server_with_history(
config: &Config,
history_capabilities: HistoryCapabilities,
mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
failed_thread_name: Option<&'static str>,
) -> Result<(
AppServerSession,
Arc<Mutex<Vec<String>>>,
JoinHandle<Result<()>>,
)> {
) -> Result<RecordingAppServer> {
let state_db =
crate::init_state_db_for_app_server_target(config, &crate::AppServerTarget::Embedded)
.await?;
@@ -107,42 +130,79 @@ async fn start_recording_app_server(
request_sink
.lock()
.expect("request recorder lock")
.push(request.method.clone());
.push(request.clone());
let request_id = request.id.clone();
let request =
serde_json::from_value::<ClientRequest>(serde_json::to_value(request)?)?;
if let ClientRequest::ThreadList { params, .. } = &request
&& let Some((root, started, release)) = blocked_thread_list.take()
let params = request.params.as_ref();
let requires_pagination = match request.method.as_str() {
"thread/start" => params
.and_then(|params| params.get("historyMode"))
.is_some_and(|mode| !mode.is_null()),
"thread/resume" | "thread/fork" => params
.and_then(|params| params.get("excludeTurns"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
"thread/turns/list" | "thread/items/list" => true,
_ => false,
};
let response = if matches!(
history_capabilities,
HistoryCapabilities::LegacyOnly
| HistoryCapabilities::LegacyOnlyUnsupportedVariant
) && requires_pagination
{
assert_eq!(params.ancestor_thread_id, Some(root.to_string()));
assert_eq!(params.sort_direction, Some(SortDirection::Desc));
let _ = started.send(());
let _ = release.await;
}
let force_failure = matches!(
&request,
ClientRequest::ThreadSetName { params, .. }
if failed_thread_name == Some(params.name.as_str())
);
let response = if force_failure {
let (code, message) = if history_capabilities
== HistoryCapabilities::LegacyOnlyUnsupportedVariant
&& request.method == "thread/start"
{
(-32602, "unknown variant \"paginated\", expected \"legacy\"")
} else {
(-32601, "method not found")
};
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error: JSONRPCErrorError {
code: -32603,
message: "forced thread/name/set failure".to_string(),
code,
data: None,
message: message.to_string(),
},
})
} else {
match embedded.request(request).await? {
Ok(result) => JSONRPCMessage::Response(JSONRPCResponse {
let request = serde_json::from_value::<ClientRequest>(
serde_json::to_value(request)?,
)?;
if let ClientRequest::ThreadList { params, .. } = &request
&& let Some((root, started, release)) = blocked_thread_list.take()
{
assert_eq!(params.ancestor_thread_id, Some(root.to_string()));
assert_eq!(params.sort_direction, Some(SortDirection::Desc));
let _ = started.send(());
let _ = release.await;
}
let force_failure = matches!(
&request,
ClientRequest::ThreadSetName { params, .. }
if failed_thread_name == Some(params.name.as_str())
);
if force_failure {
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
result,
}),
Err(error) => JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error,
}),
error: JSONRPCErrorError {
code: -32603,
message: "forced thread/name/set failure".to_string(),
data: None,
},
})
} else {
match embedded.request(request).await? {
Ok(result) => JSONRPCMessage::Response(JSONRPCResponse {
id: request_id,
result,
}),
Err(error) => JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error,
}),
}
}
};
websocket
@@ -180,12 +240,40 @@ async fn start_recording_app_server(
))
}
#[tokio::test]
async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> {
fn create_legacy_history_rollout(config: &Config) -> Result<ThreadId> {
let thread_id = create_fake_rollout(
config.codex_home.as_path(),
"2026-01-02T00-00-00",
"2026-01-02T00:00:00Z",
"legacy history",
Some(config.model_provider_id.as_str()),
/*git_info*/ None,
)
.map_err(|err| color_eyre::eyre::eyre!("failed to create history rollout: {err}"))?;
Ok(ThreadId::from_string(&thread_id)?)
}
fn recorded_params(requests: &RecordedRequests, method: &str) -> Vec<serde_json::Value> {
requests
.lock()
.expect("request recorder lock")
.iter()
.filter(|request| request.method == method)
.map(|request| request.params.clone().unwrap_or(serde_json::Value::Null))
.collect()
}
async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs());
Ok((app, codex_home))
}
#[tokio::test]
async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> {
let (mut app, codex_home) = make_history_test_app().await?;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100);
let thread_id = create_fake_paginated_rollout(
codex_home.path(),
@@ -377,6 +465,277 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() ->
Ok(())
}
#[tokio::test]
async fn transcript_home_loads_every_older_history_page() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs());
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(2);
let thread_id = create_fake_paginated_rollout(
codex_home.path(),
"2026-01-02T00-00-00",
"2026-01-02T00:00:00Z",
"multi-page transcript",
Some(app.config.model_provider_id.as_str()),
/*git_info*/ None,
)
.map_err(|error| color_eyre::eyre::eyre!("failed to create paginated rollout: {error}"))?;
let thread_id = ThreadId::from_string(&thread_id)?;
let path = rollout_path(
codex_home.path(),
"2026-01-02T00-00-00",
&thread_id.to_string(),
);
let mut records = std::fs::read_to_string(&path)?
.lines()
.map(serde_json::from_str::<serde_json::Value>)
.collect::<Result<Vec<_>, _>>()?;
let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "multi-page-turn".to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}))
.chain((0..305).map(|index| {
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: "multi-page-turn".to_string(),
item: TurnItem::AgentMessage(AgentMessageItem {
id: format!("history-item-{index}"),
content: vec![AgentMessageContent::Text {
text: format!("history output {index}"),
}],
phase: None,
memory_citation: None,
}),
started_at_ms: None,
completed_at_ms: 0,
})
}));
for event in events {
records.push(serde_json::json!({
"timestamp": "2026-01-02T00:00:00Z",
"ordinal": records.len(),
"type": "event_msg",
"payload": serde_json::to_value(event)?,
}));
}
let records = records
.into_iter()
.map(|record| record.to_string())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, format!("{records}\n"))?;
let (mut app_server, requests, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let started = app_server
.resume_thread(
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
let initial_cells = crate::thread_transcript::thread_items_to_transcript_cells(
Some(thread_id),
&app.config.cwd,
started.turns.iter().flat_map(|turn| turn.items.clone()),
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
);
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
app.transcript_cells = initial_cells;
app.scrollback_has_older_history = app_server.has_older_history(thread_id);
assert!(app.scrollback_has_older_history);
while app_event_rx.try_recv().is_ok() {}
let initial_page_requests = recorded_params(&requests, "thread/items/list").len();
let mut tui = crate::tui::test_support::make_test_tui()?;
app.open_transcript_overlay(&mut tui);
app.handle_backtrack_overlay_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
)
.await?;
while app_server.has_older_history(thread_id) {
let event = tokio::time::timeout(Duration::from_secs(5), app_event_rx.recv())
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("history event channel closed"))?;
if matches!(event, AppEvent::OlderThreadHistoryLoaded { .. }) {
app.handle_event(&mut tui, &mut app_server, event).await?;
}
}
assert!(recorded_params(&requests, "thread/items/list").len() >= initial_page_requests + 3);
assert!(app.transcript_cells.iter().any(|cell| {
cell.display_lines(/*width*/ 80)
.iter()
.any(|line| line.to_string().contains("history output 0"))
}));
let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else {
panic!("expected transcript overlay after Home navigation");
};
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12,
);
let mut buffer = Buffer::empty(area);
overlay.render(area, &mut buffer);
let visible = (area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(visible.contains("history output 0"), "{visible}");
assert!(!visible.contains("history output 304"), "{visible}");
app_server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Result<()> {
let (app, _codex_home) = make_history_test_app().await?;
let legacy_thread_id = create_legacy_history_rollout(&app.config)?;
let (mut app_server, requests, proxy) = start_recording_app_server_with_history(
&app.config,
HistoryCapabilities::LegacyOnly,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let started = app_server.start_thread(&app.config).await?;
let resumed = app_server
.resume_thread(
app.config.clone(),
legacy_thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
let forked = app_server
.fork_thread(app.config.clone(), legacy_thread_id)
.await?;
assert_ne!(started.session.thread_id, legacy_thread_id);
assert_eq!(resumed.session.thread_id, legacy_thread_id);
assert_ne!(forked.session.thread_id, legacy_thread_id);
let starts = recorded_params(&requests, "thread/start");
assert_eq!(starts.len(), 2);
assert_eq!(starts[0]["historyMode"], "paginated");
assert_eq!(starts[1]["historyMode"], serde_json::Value::Null);
for method in ["thread/resume", "thread/fork"] {
let params = recorded_params(&requests, method);
assert_eq!(params.len(), 1, "legacy {method} must not be reprobed");
assert_ne!(params[0]["excludeTurns"], true);
}
assert!(recorded_params(&requests, "thread/turns/list").is_empty());
assert!(recorded_params(&requests, "thread/items/list").is_empty());
app_server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn remote_legacy_history_start_retries_unsupported_paginated_variant() -> Result<()> {
let (app, _codex_home) = make_history_test_app().await?;
let (mut app_server, requests, proxy) = start_recording_app_server_with_history(
&app.config,
HistoryCapabilities::LegacyOnlyUnsupportedVariant,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
app_server.start_thread(&app.config).await?;
let starts = recorded_params(&requests, "thread/start");
assert_eq!(starts.len(), 2);
assert_eq!(starts[0]["historyMode"], "paginated");
assert_eq!(starts[1]["historyMode"], serde_json::Value::Null);
app_server.shutdown().await?;
proxy.await??;
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LegacyHistoryRequest {
Resume,
Fork,
}
async fn assert_remote_legacy_history_retry(request: LegacyHistoryRequest) -> Result<()> {
let (app, _codex_home) = make_history_test_app().await?;
let legacy_thread_id = create_legacy_history_rollout(&app.config)?;
let (mut app_server, requests, proxy) = start_recording_app_server_with_history(
&app.config,
HistoryCapabilities::LegacyOnly,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let method = match request {
LegacyHistoryRequest::Resume => {
let resumed = app_server
.resume_thread(
app.config.clone(),
legacy_thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
assert_eq!(resumed.session.thread_id, legacy_thread_id);
"thread/resume"
}
LegacyHistoryRequest::Fork => {
let forked = app_server
.fork_thread(app.config.clone(), legacy_thread_id)
.await?;
assert_ne!(forked.session.thread_id, legacy_thread_id);
"thread/fork"
}
};
let attempts = recorded_params(&requests, method);
if request == LegacyHistoryRequest::Resume {
assert_eq!(attempts.len(), 2);
assert_eq!(attempts[0]["excludeTurns"], true);
} else {
assert_eq!(attempts.len(), 1);
}
assert_ne!(
attempts.last().expect("history request")["excludeTurns"],
true
);
assert!(recorded_params(&requests, "thread/turns/list").is_empty());
assert!(recorded_params(&requests, "thread/items/list").is_empty());
app_server.shutdown().await?;
proxy.await??;
Ok(())
}
#[tokio::test]
async fn remote_legacy_history_resume_retries_generic_method_not_found() -> Result<()> {
assert_remote_legacy_history_retry(LegacyHistoryRequest::Resume).await
}
#[tokio::test]
async fn remote_legacy_history_fork_avoids_unsupported_fields() -> Result<()> {
assert_remote_legacy_history_retry(LegacyHistoryRequest::Fork).await
}
#[test]
fn fresh_session_applies_requested_name() -> Result<()> {
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
@@ -420,7 +779,7 @@ fn fresh_session_applies_requested_name() -> Result<()> {
.lock()
.expect("request recorder lock")
.iter()
.any(|method| method == "thread/name/set"),
.any(|request| request.method == "thread/name/set"),
"fresh session should be named through the app server"
);
let thread = app_server
@@ -664,7 +1023,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> {
.lock()
.expect("request recorder lock")
.iter()
.filter(|method| *method == "thread/list")
.filter(|request| request.method == "thread/list")
.count(),
1
);
+15 -26
View File
@@ -27,8 +27,6 @@ use std::sync::Arc;
use crate::app::App;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
use crate::app_server_session::thread_items_page_params;
use crate::bottom_pane::LocalImageAttachment;
use crate::chatwidget::ChatWidget;
use crate::chatwidget::UserMessage;
@@ -38,11 +36,10 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::Overlay;
use crate::pager_overlay::TranscriptHistoryState;
use crate::tui;
use crate::tui::TuiEvent;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
@@ -104,30 +101,17 @@ impl App {
&& matches!(key_event.code, KeyCode::Esc | KeyCode::Left)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)))
&& let Some(thread_id) = self.chat_widget.thread_id()
&& let Some(cursor) = app_server.begin_older_history_page(thread_id)
&& app_server.has_older_history(thread_id)
&& self.request_older_history_page(app_server, thread_id)
{
let request_id = app_server.next_request_id();
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = request_handle
.request_typed::<ThreadItemsListResponse>(ClientRequest::ThreadItemsList {
request_id,
params: thread_items_page_params(
thread_id,
/*turn_id*/ None,
Some(cursor.clone()),
HISTORY_ITEM_PAGE_LIMIT,
),
})
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::OlderThreadHistoryLoaded {
thread_id,
cursor,
result,
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
overlay.set_history_state(if overlay.should_load_from_start(*key_event) {
TranscriptHistoryState::LoadingBeginning
} else {
TranscriptHistoryState::LoadingOlder
});
});
}
tui.frame_requester().schedule_frame();
}
if self.backtrack.overlay_preview_active {
match event {
@@ -237,6 +221,11 @@ impl App {
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
if self.scrollback_has_older_history
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
{
overlay.set_history_state(TranscriptHistoryState::Partial);
}
tui.frame_requester().schedule_frame();
}
+249 -66
View File
@@ -143,6 +143,7 @@ use uuid::Uuid;
const JSONRPC_INVALID_REQUEST: i64 = -32600;
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
const JSONRPC_INVALID_PARAMS: i64 = -32602;
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again.";
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
@@ -158,10 +159,77 @@ enum ForkPresentation {
SideConversation,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ThreadHistorySupport {
Paginated,
LegacyOnly,
}
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
color_eyre::eyre::eyre!("{context}: {err}")
}
fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool {
if source.code == JSONRPC_METHOD_NOT_FOUND {
return true;
}
if !matches!(
source.code,
JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS
) {
return false;
}
let message = source.message.to_ascii_lowercase();
[
"historymode",
"history mode",
"excludeturns",
"exclude turns",
"thread/turns/list",
"thread/items/list",
]
.into_iter()
.any(|field| message.contains(field))
|| (message.contains("paginated")
&& ["unknown variant", "unsupported variant", "invalid enum"]
.into_iter()
.any(|error| message.contains(error)))
}
async fn request_thread_start_with_history_fallback(
request_handle: &AppServerRequestHandle,
request_id: RequestId,
mut params: ThreadStartParams,
) -> std::result::Result<(ThreadStartResponse, ThreadHistorySupport), TypedRequestError> {
match request_handle
.request_typed(ClientRequest::ThreadStart {
request_id,
params: params.clone(),
})
.await
{
Ok(response) => Ok((response, ThreadHistorySupport::Paginated)),
Err(TypedRequestError::Server { source, .. })
if params.history_mode.is_some() && is_history_pagination_unsupported(&source) =>
{
params.history_mode = None;
let response = request_handle
.request_typed(ClientRequest::ThreadStart {
request_id: RequestId::String(format!(
"legacy-thread-start-{}",
Uuid::new_v4()
)),
params,
})
.await?;
Ok((response, ThreadHistorySupport::LegacyOnly))
}
Err(err) => Err(err),
}
}
fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool {
source.code == JSONRPC_METHOD_NOT_FOUND
|| (source.code == JSONRPC_INVALID_REQUEST
@@ -196,6 +264,7 @@ pub(crate) struct AppServerSession {
history_pagination: HashMap<ThreadId, history::ThreadHistoryPagination>,
remote_cwd_override: Option<PathBuf>,
thread_params_mode: ThreadParamsMode,
history_support: ThreadHistorySupport,
thread_settings_update_supported: bool,
default_model: Option<String>,
available_models: Vec<ModelPreset>,
@@ -269,6 +338,7 @@ impl AppServerSession {
history_pagination: HashMap::new(),
remote_cwd_override: None,
thread_params_mode,
history_support: ThreadHistorySupport::Paginated,
thread_settings_update_supported: true,
default_model: None,
available_models: Vec::new(),
@@ -523,21 +593,25 @@ impl AppServerSession {
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(config);
let response: ThreadStartResponse = self
.client
.request_typed(ClientRequest::ThreadStart {
request_id,
params: thread_start_params_from_config(
&session_config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
session_start_source,
),
})
.await
.map_err(|err| {
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
})?;
let mut params = thread_start_params_from_config(
&session_config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
session_start_source,
);
if self.history_support == ThreadHistorySupport::LegacyOnly {
params.history_mode = None;
}
let request_handle = self.request_handle();
let (response, history_support) =
request_thread_start_with_history_fallback(&request_handle, request_id, params)
.await
.map_err(|err| {
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
})?;
if history_support == ThreadHistorySupport::LegacyOnly {
self.history_support = ThreadHistorySupport::LegacyOnly;
}
started_thread_from_start_response(response, config, self.thread_params_mode()).await
}
@@ -553,29 +627,47 @@ impl AppServerSession {
} else {
self.session_config_with_effective_service_tier(&config)
};
let exclude_turns = self
.history_pagination
.get(&thread_id)
.is_some_and(|state| state.history_mode == ThreadHistoryMode::Paginated);
let mut response: ThreadResumeResponse = self
let mut params = thread_resume_params_from_config(
session_config,
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
model_settings,
);
params.exclude_turns = self.history_support == ThreadHistorySupport::Paginated
&& self
.history_pagination
.get(&thread_id)
.is_none_or(|state| state.history_mode == ThreadHistoryMode::Paginated);
let mut response: ThreadResumeResponse = match self
.client
.request_typed(ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
exclude_turns,
..thread_resume_params_from_config(
session_config,
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
model_settings,
)
},
params: params.clone(),
})
.await
.map_err(|err| {
bootstrap_request_error("thread/resume failed during TUI bootstrap", err)
})?;
{
Ok(response) => response,
Err(TypedRequestError::Server { source, .. })
if params.exclude_turns && is_history_pagination_unsupported(&source) =>
{
self.history_support = ThreadHistorySupport::LegacyOnly;
params.exclude_turns = false;
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadResume { request_id, params })
.await
.map_err(|err| {
bootstrap_request_error("thread/resume failed during TUI bootstrap", err)
})?
}
Err(err) => {
return Err(bootstrap_request_error(
"thread/resume failed during TUI bootstrap",
err,
));
}
};
self.hydrate_initial_thread_history(
&mut response.thread,
response.turns_backwards_cursor.clone(),
@@ -660,34 +752,54 @@ impl AppServerSession {
.ok(),
ForkPresentation::SideConversation => None,
};
let exclude_turns = fork_parent
.as_ref()
.is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated)
|| presentation == ForkPresentation::SideConversation;
let exclude_turns = self.history_support == ThreadHistorySupport::Paginated
&& (fork_parent
.as_ref()
.is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated)
|| presentation == ForkPresentation::SideConversation);
let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(&config);
let response: ThreadForkResponse = self
let mut params = ThreadForkParams {
last_turn_id,
before_turn_id,
defer_goal_continuation: goal_continuation == ForkGoalContinuation::DeferUntilNextTurn,
exclude_turns,
..thread_fork_params_from_config(
session_config,
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
)
};
let response: ThreadForkResponse = match self
.client
.request_typed(ClientRequest::ThreadFork {
request_id,
params: ThreadForkParams {
last_turn_id,
before_turn_id,
defer_goal_continuation: goal_continuation
== ForkGoalContinuation::DeferUntilNextTurn,
exclude_turns,
..thread_fork_params_from_config(
session_config,
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
)
},
params: params.clone(),
})
.await
.map_err(|err| {
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
})?;
{
Ok(response) => response,
Err(TypedRequestError::Server { source, .. })
if params.exclude_turns && is_history_pagination_unsupported(&source) =>
{
self.history_support = ThreadHistorySupport::LegacyOnly;
params.exclude_turns = false;
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadFork { request_id, params })
.await
.map_err(|err| {
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
})?
}
Err(err) => {
return Err(bootstrap_request_error(
"thread/fork failed during TUI bootstrap",
err,
));
}
};
let mut response = response;
if presentation == ForkPresentation::Regular && !response.thread.ephemeral {
self.hydrate_initial_thread_history(
@@ -1351,18 +1463,19 @@ pub(crate) async fn start_thread_with_request_handle(
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let response: ThreadStartResponse = request_handle
.request_typed(ClientRequest::ThreadStart {
request_id: RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())),
params: thread_start_params_from_config(
&config,
thread_params_mode,
remote_cwd_override.as_deref(),
/*session_start_source*/ None,
),
})
.await
.map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
let request_id = RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4()));
let params = thread_start_params_from_config(
&config,
thread_params_mode,
remote_cwd_override.as_deref(),
/*session_start_source*/ None,
);
let (response, _history_support) =
request_thread_start_with_history_fallback(&request_handle, request_id, params)
.await
.map_err(|err| {
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
})?;
started_thread_from_start_response(response, &config, thread_params_mode).await
}
@@ -1596,6 +1709,7 @@ fn thread_start_params_from_config(
permissions,
config: config_request_overrides_from_config(config),
ephemeral: Some(config.ephemeral),
history_mode: (!config.ephemeral).then_some(ThreadHistoryMode::Paginated),
session_start_source,
thread_source: Some(ThreadSource::User),
developer_instructions: with_terminal_visualization_instructions(
@@ -2069,6 +2183,75 @@ mod tests {
}
}
#[test]
fn history_pagination_compat_detects_unsupported_server_fields() {
let cases = [
(JSONRPC_INVALID_PARAMS, "unknown field `historyMode`", true),
(
JSONRPC_INVALID_REQUEST,
"thread/resume.excludeTurns requires experimentalApi capability",
true,
),
(
JSONRPC_INVALID_REQUEST,
"thread/fork.excludeTurns requires experimentalApi capability",
true,
),
(
JSONRPC_METHOD_NOT_FOUND,
"unknown method thread/turns/list",
true,
),
(JSONRPC_METHOD_NOT_FOUND, "method not found", true),
(
JSONRPC_INVALID_PARAMS,
"unknown variant \"paginated\", expected \"legacy\"",
true,
),
(
JSONRPC_INVALID_PARAMS,
"invalid enum value `paginated`",
true,
),
(
JSONRPC_INVALID_PARAMS,
"paginated thread was not found",
false,
),
(JSONRPC_INVALID_PARAMS, "invalid thread id", false),
];
for (code, message, expected) in cases {
let source = JSONRPCErrorError {
code,
data: None,
message: message.to_string(),
};
assert_eq!(
is_history_pagination_unsupported(&source),
expected,
"{message}"
);
}
}
#[tokio::test]
async fn ephemeral_thread_start_does_not_request_paginated_history() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config = build_config(&temp_dir).await;
config.ephemeral = true;
let params = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
assert_eq!(params.ephemeral, Some(true));
assert_eq!(params.history_mode, None);
}
#[tokio::test]
async fn thread_start_params_include_cwd_for_embedded_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
@@ -130,7 +130,7 @@ impl AppServerSession {
.wrap_err("failed to load a bounded thread item page")
}
async fn thread_turns_page(
pub(crate) async fn thread_turns_page(
&mut self,
thread_id: ThreadId,
cursor: Option<String>,
+143 -7
View File
@@ -154,6 +154,8 @@ struct PagerView {
keymap: PagerKeymap,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// Percentages are meaningful only when the full scrollable history is known.
scroll_percentage_visible: bool,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
}
@@ -172,6 +174,7 @@ impl PagerView {
keymap,
last_content_height: None,
last_rendered_height: None,
scroll_percentage_visible: true,
pending_scroll_chunk: None,
}
}
@@ -261,6 +264,9 @@ impl PagerView {
Span::from("─".repeat(sep_rect.width as usize))
.dim()
.render(sep_rect, buf);
if !self.scroll_percentage_visible {
return;
}
let percent = if total_len == 0 {
100
} else {
@@ -471,6 +477,35 @@ impl Renderable for HyperlinkLinesRenderable {
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum TranscriptHistoryState {
#[default]
Idle,
LoadingOlder,
LoadingBeginning,
Partial,
Failed,
Complete,
}
impl TranscriptHistoryState {
fn has_unloaded_history(self) -> bool {
matches!(
self,
Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed
)
}
fn session_header_placeholder(self) -> Option<&'static str> {
match self {
Self::LoadingOlder | Self::LoadingBeginning => Some("Loading earlier messages..."),
Self::Partial => Some("Earlier messages are available — scroll up to load them"),
Self::Failed => Some("Earlier messages unavailable — scroll up to retry"),
Self::Idle | Self::Complete => None,
}
}
}
pub(crate) struct TranscriptOverlay {
/// Pager UI state and the renderables currently displayed.
///
@@ -482,6 +517,7 @@ pub(crate) struct TranscriptOverlay {
highlight_cell: Option<usize>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
history_state: TranscriptHistoryState,
is_done: bool,
}
@@ -508,7 +544,11 @@ impl TranscriptOverlay {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self {
view: PagerView::new(
Self::render_cells(&transcript_cells, /*highlight_cell*/ None),
Self::render_cells(
&transcript_cells,
/*highlight_cell*/ None,
TranscriptHistoryState::Idle,
),
"T R A N S C R I P T".to_string(),
usize::MAX,
keymap,
@@ -516,18 +556,45 @@ impl TranscriptOverlay {
cells: transcript_cells,
highlight_cell: None,
live_tail_key: None,
history_state: TranscriptHistoryState::Idle,
is_done: false,
}
}
pub(crate) fn set_history_state(
&mut self,
state: TranscriptHistoryState,
) -> TranscriptHistoryState {
let previous = self.history_state;
if previous == state {
return previous;
}
if previous == TranscriptHistoryState::LoadingBeginning
&& state == TranscriptHistoryState::Complete
{
self.view.scroll_offset = 0;
}
self.history_state = state;
self.view.scroll_percentage_visible = !state.has_unloaded_history();
if self
.cells
.iter()
.any(|cell| cell.as_any().is::<SessionInfoCell>())
{
self.rebuild_renderables();
}
previous
}
fn render_cells(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
history_state: TranscriptHistoryState,
) -> Vec<Box<dyn Renderable>> {
cells
.iter()
.enumerate()
.map(|(i, cell)| Self::render_cell(cell, i, highlight_cell))
.map(|(i, cell)| Self::render_cell(cell, i, highlight_cell, history_state))
.collect()
}
@@ -536,7 +603,13 @@ impl TranscriptOverlay {
cell: &Arc<dyn HistoryCell>,
index: usize,
highlight_cell: Option<usize>,
history_state: TranscriptHistoryState,
) -> Box<dyn Renderable> {
if cell.as_any().is::<SessionInfoCell>()
&& let Some(placeholder) = history_state.session_header_placeholder()
{
return Box::new(Line::from(placeholder).dim());
}
let cell_renderable = CellRenderable {
cell: cell.clone(),
highlighted: highlight_cell == Some(index),
@@ -571,7 +644,12 @@ impl TranscriptOverlay {
let follow_bottom = self.view.is_scrolled_to_bottom();
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
let cell_renderable = Self::render_cell(&cell, self.cells.len(), self.highlight_cell);
let cell_renderable = Self::render_cell(
&cell,
self.cells.len(),
self.highlight_cell,
self.history_state,
);
self.cells.push(cell);
self.view.renderables.push(cell_renderable);
if let Some(tail) = tail_renderable {
@@ -600,7 +678,7 @@ impl TranscriptOverlay {
/// Returns whether an upward navigation is close enough to request older history.
pub(crate) fn should_load_older(&self, key_event: KeyEvent) -> bool {
self.view.keymap.jump_top.is_pressed(key_event)
self.should_load_from_start(key_event)
|| (self.view.scroll_offset
<= self.view.last_content_height.unwrap_or(/*default*/ 0)
&& (self.view.keymap.scroll_up.is_pressed(key_event)
@@ -608,6 +686,10 @@ impl TranscriptOverlay {
|| self.view.keymap.half_page_up.is_pressed(key_event)))
}
pub(crate) fn should_load_from_start(&self, key_event: KeyEvent) -> bool {
self.view.keymap.jump_top.is_pressed(key_event)
}
/// Prepends history without moving visible content and returns its insertion index.
pub(crate) fn prepend(&mut self, cells: Vec<Arc<dyn HistoryCell>>, width: u16) -> usize {
if cells.is_empty() {
@@ -778,7 +860,8 @@ impl TranscriptOverlay {
fn rebuild_renderables(&mut self) {
let tail_renderable = self.take_live_tail_renderable();
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
self.view.renderables =
Self::render_cells(&self.cells, self.highlight_cell, self.history_state);
if let Some(tail) = tail_renderable {
self.view.renderables.push(tail);
}
@@ -844,8 +927,32 @@ impl TranscriptOverlay {
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(top, buf);
self.render_history_state(top, buf);
self.render_hints(bottom, buf);
}
fn render_history_state(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let label = match self.history_state {
TranscriptHistoryState::Idle => return,
TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => {
" loading older history... "
}
TranscriptHistoryState::Partial => " partial history | PgUp for earlier ",
TranscriptHistoryState::Failed => " history unavailable | PgUp to retry ",
TranscriptHistoryState::Complete => " start of history ",
};
let width = (label.chars().count() as u16).min(area.width);
let status_area = Rect::new(
area.right().saturating_sub(width),
area.y,
width,
/*height*/ 1,
);
Span::from(label).dim().render(status_area, buf);
}
}
impl TranscriptOverlay {
@@ -1117,8 +1224,12 @@ mod tests {
lines: vec![Line::from("recent")],
})]);
assert!(overlay.should_load_older(KeyEvent::new(
KeyCode::Home,
let home = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE);
assert!(overlay.should_load_older(home));
assert!(overlay.should_load_from_start(home));
assert!(!overlay.should_load_from_start(KeyEvent::new(
KeyCode::PageUp,
crossterm::event::KeyModifiers::NONE,
)));
}
@@ -1142,6 +1253,31 @@ mod tests {
);
}
#[test]
fn transcript_overlay_snapshots_paginated_history_states() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("recent transcript")],
})]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 72, /*height*/ 10,
);
let mut snapshots = String::new();
for (name, state) in [
("loading", TranscriptHistoryState::LoadingOlder),
("partial", TranscriptHistoryState::Partial),
("failed", TranscriptHistoryState::Failed),
("complete", TranscriptHistoryState::Complete),
] {
overlay.set_history_state(state);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
snapshots.push_str(&format!("--- {name} ---\n{}", buffer_to_text(&buf, area)));
}
assert_snapshot!("transcript_overlay_paginated_history_states", snapshots);
}
#[test]
fn transcript_overlay_snapshot_basic() {
// Prepare a transcript overlay with a few lines
@@ -0,0 +1,47 @@
---
source: tui/src/pager_overlay.rs
expression: snapshots
---
--- loading ---
/ T R A N S C R I P T / / / / / / / / / / / / loading older history...
recent transcript
~
~
~
~
────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
--- partial ---
/ T R A N S C R I P T / / / / / / / partial history | PgUp for earlier
recent transcript
~
~
~
~
────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
--- failed ---
/ T R A N S C R I P T / / / / / / / history unavailable | PgUp to retry
recent transcript
~
~
~
~
────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
--- complete ---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / start of history
recent transcript
~
~
~
~
───────────────────────────────────────────────────────────────── 100% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev