mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
+47
@@ -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
|
||||
Reference in New Issue
Block a user