Improve paginated TUI history loading (#36949)

## Why

Paginated sessions can have visible messages beyond the first item page, so
bounded hydration could leave resumed history or transcript previews empty and
the transcript viewer incomplete.

## What changed

- Scan paginated history until the configured rendered-row budget is filled,
  while retaining the configured scrollback beyond the visible viewport.
- Fully hydrate transcripts opened from the resume picker and build previews
  from bounded item pages, including when non-visible items precede messages.
- Stop pagination on repeated cursors and let `Esc` cancel an in-flight
  transcript load.
- Replay restored token usage for cold metadata-only resumes when its stored
  turn can be identified.

## Testing

- Add coverage for paginated and legacy transcript loading, bounded resume
  hydration, repeated cursors, transcript cancellation, and resize reflow.

GitOrigin-RevId: 2ce69f7d5eae88e2b09a27c333b00b1830ff17bf
This commit is contained in:
Felipe Coury
2026-08-04 19:05:45 +00:00
committed by copyberry
parent 8bfa49e350
commit 3b8d22ec2c
13 changed files with 721 additions and 125 deletions
+2 -2
View File
@@ -349,7 +349,7 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. A cold paginated resume can still replay persisted `thread/tokenUsage/updated` when it can identify the corresponding stored turn; resuming an already-loaded thread waits for the next live update.
Paginated threads keep the same resume contract as legacy threads. A default resume materializes the full projected history into `thread.turns`; `excludeTurns: true` keeps that array empty and includes `turnsBackwardsCursor` and `itemsBackwardsCursor` for the durable history visible at the resume boundary. Pass each cursor directly to its matching list API with `sortDirection: "desc"`; the first page includes the cursor's head row, while newer records arrive through live notifications. Either cursor is `null` when there is no durable row yet.
@@ -405,7 +405,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
{ "method": "thread/started", "params": { "thread": { … } } }
```
Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage. Ephemeral forks of paginated threads require `excludeTurns: true`.
Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. Metadata-only forks do not replay restored `thread/tokenUsage/updated`. Ephemeral forks of paginated threads require `excludeTurns: true`.
### Example: List threads (with pagination & filters)
+1
View File
@@ -1090,6 +1090,7 @@ See the Codex keymap documentation for supported actions and examples."
if let Some(entry) = startup_hooks_browser {
app.chat_widget.open_hooks_browser(entry);
}
app.update_visible_history_rows(tui.terminal.last_known_screen_size);
let initial_session_started_at = Instant::now();
if let Some(started) = initial_started_thread {
let thread_id = started.session.thread_id;
+24 -1
View File
@@ -19,6 +19,7 @@ use std::sync::Arc;
use std::time::Instant;
use color_eyre::eyre::Result;
use ratatui::layout::Size;
use ratatui::text::Line;
use super::App;
@@ -192,7 +193,8 @@ impl App {
return;
}
let max_rows = self.resize_reflow_max_rows();
let max_rows =
crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow);
if let Some(buffer) = &mut self.initial_history_replay_buffer {
if let Some(max_rows) = max_rows {
Self::buffer_initial_history_replay_display_lines(buffer, display, max_rows);
@@ -239,6 +241,19 @@ impl App {
crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow)
}
pub(super) fn update_visible_history_rows(&mut self, screen_size: Size) {
let width = screen_size.width.max(/*other*/ 1);
let viewport_height = self
.with_chat_widget_frame(width, |desired_height, _| desired_height)
.min(screen_size.height);
self.transcript_reflow.set_visible_history_rows(
screen_size
.height
.saturating_sub(viewport_height)
.max(/*other*/ 1),
);
}
fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> {
if tui.is_alt_screen_active() {
tui.terminal.clear_visible_screen()?;
@@ -316,6 +331,10 @@ impl App {
last_known_screen_size: ratatui::layout::Size,
frame_requester: &tui::FrameRequester,
) -> bool {
if size != last_known_screen_size || self.transcript_reflow.visible_history_rows().is_none()
{
self.update_visible_history_rows(size);
}
let width = self.transcript_reflow.note_width(size.width);
let reflow_needed = self.transcript_reflow.reflow_needed_for_width(size.width);
let height_changed = size.height != last_known_screen_size.height;
@@ -570,3 +589,7 @@ impl App {
< self.transcript_cells.len()
}
}
#[cfg(test)]
#[path = "resize_reflow_tests.rs"]
mod tests;
+144
View File
@@ -0,0 +1,144 @@
use super::*;
use crate::app::test_support::make_test_app;
use crate::history_cell::PlainHistoryCell;
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
use pretty_assertions::assert_eq;
fn plain_history_cells(count: usize) -> Vec<Arc<dyn HistoryCell>> {
(0..count)
.map(|index| {
Arc::new(PlainHistoryCell::new(vec![Line::from(format!(
"cell {index}"
))])) as Arc<dyn HistoryCell>
})
.collect()
}
fn rendered_line_text(line: &HyperlinkLine) -> String {
line.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[tokio::test]
async fn resize_reflow_preserves_configured_scrollback_beyond_the_visible_viewport() {
let mut app = make_test_app().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32);
app.transcript_cells = plain_history_cells(/*count*/ 64);
let screen_size = Size::new(/*width*/ 80, /*height*/ 24);
let chat_height = app.with_chat_widget_frame(screen_size.width, |height, _| height);
let visible_history_rows = screen_size
.height
.saturating_sub(chat_height)
.max(/*other*/ 1);
app.update_visible_history_rows(screen_size);
let rendered = app.render_transcript_lines_for_reflow(screen_size.width);
assert_eq!(app.resize_reflow_max_rows(), Some(32));
assert_eq!(rendered.lines.len(), 32);
assert!(rendered.lines.len() > usize::from(visible_history_rows));
assert_eq!(
rendered.lines.last().map(rendered_line_text),
Some("cell 63".to_string())
);
}
#[tokio::test]
async fn initial_resume_replay_retains_scrollback_beyond_the_visible_viewport() -> Result<()> {
let mut app = make_test_app().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32);
let screen_size = Size::new(/*width*/ 80, /*height*/ 24);
app.update_visible_history_rows(screen_size);
let visible_history_rows = app
.transcript_reflow
.visible_history_rows()
.expect("visible history row budget");
app.begin_initial_history_replay_buffer();
let mut tui = crate::tui::test_support::make_test_tui()?;
for cell in plain_history_cells(/*count*/ 24) {
app.insert_history_cell_lines_with_initial_replay_buffer(
&mut tui,
cell.as_ref(),
screen_size.width,
);
}
let retained_lines = &app
.initial_history_replay_buffer
.as_ref()
.expect("initial replay buffer should remain active")
.retained_lines;
assert_eq!(retained_lines.len(), 32);
assert!(retained_lines.len() > usize::from(visible_history_rows));
insta::assert_snapshot!(
retained_lines
.iter()
.rev()
.take(/*n*/ 3)
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n"),
@r"
cell 23
cell 22
"
);
Ok(())
}
#[tokio::test]
async fn resize_reflow_preserves_configured_scrollback_when_the_terminal_height_changes() {
let mut app = make_test_app().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(48);
app.transcript_cells = plain_history_cells(/*count*/ 64);
app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 24));
let smaller = app.render_transcript_lines_for_reflow(/*width*/ 80);
app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 48));
let larger = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(smaller.lines.len(), 48);
assert_eq!(larger.lines.len(), 48);
assert_eq!(
smaller
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
larger
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>()
);
assert_eq!(
larger.lines.last().map(rendered_line_text),
Some("cell 63".to_string())
);
}
#[tokio::test]
async fn resize_reflow_preserves_explicitly_unlimited_history() {
let mut app = make_test_app().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
app.transcript_cells = plain_history_cells(/*count*/ 20);
app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 24));
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(app.resize_reflow_max_rows(), None);
assert_eq!(rendered.lines.len(), 39);
assert_eq!(
rendered.lines.first().map(rendered_line_text),
Some("cell 0".to_string())
);
assert_eq!(
rendered.lines.last().map(rendered_line_text),
Some("cell 19".to_string())
);
}
@@ -186,6 +186,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() ->
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(100);
let thread_id = create_fake_paginated_rollout(
codex_home.path(),
"2026-01-02T00-00-00",
+7 -9
View File
@@ -28,6 +28,7 @@ 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;
@@ -40,9 +41,7 @@ use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
@@ -114,13 +113,12 @@ impl App {
let result = request_handle
.request_typed::<ThreadItemsListResponse>(ClientRequest::ThreadItemsList {
request_id,
params: ThreadItemsListParams {
thread_id: thread_id.to_string(),
turn_id: None,
cursor: Some(cursor.clone()),
limit: Some(HISTORY_ITEM_PAGE_LIMIT),
sort_direction: Some(SortDirection::Desc),
},
params: thread_items_page_params(
thread_id,
/*turn_id*/ None,
Some(cursor.clone()),
HISTORY_ITEM_PAGE_LIMIT,
),
})
.await
.map_err(|err| err.to_string());
+6
View File
@@ -7,6 +7,9 @@ mod fs;
mod history;
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
pub(crate) use history::HistoryHydrationScope;
pub(crate) use history::thread_items_page_params;
use crate::bottom_pane::FeedbackAudience;
use crate::legacy_core::config::Config;
@@ -578,6 +581,7 @@ impl AppServerSession {
response.turns_backwards_cursor.clone(),
response.items_backwards_cursor.clone(),
Some(&config),
HistoryHydrationScope::Initial,
)
.await?;
let fork_parent_title = self
@@ -691,6 +695,7 @@ impl AppServerSession {
/*turn_cursor*/ None,
/*item_cursor*/ None,
Some(&config),
HistoryHydrationScope::Initial,
)
.await?;
}
@@ -824,6 +829,7 @@ impl AppServerSession {
/*turn_cursor*/ None,
/*item_cursor*/ None,
/*config*/ None,
HistoryHydrationScope::Initial,
)
.await?;
Ok(response.thread)
+121 -42
View File
@@ -1,11 +1,13 @@
//! Bounded app-server transcript loading for resume, fork, and transcript views.
use std::collections::HashSet;
use super::AppServerSession;
use crate::history_cell::HistoryRenderMode;
use crate::legacy_core::config::Config;
use crate::resize_reflow_cap::resize_reflow_max_rows;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_to_transcript_cells;
use crate::thread_transcript::thread_items_to_transcript_cells;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::Thread;
@@ -23,12 +25,47 @@ use color_eyre::eyre::WrapErr;
pub(crate) const INITIAL_HISTORY_TURN_LIMIT: u32 = 5;
pub(crate) const HISTORY_ITEM_PAGE_LIMIT: u32 = 100;
pub(crate) const HISTORY_ITEM_SCAN_LIMIT: usize = 4 * HISTORY_ITEM_PAGE_LIMIT as usize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HistoryHydrationScope {
Initial,
Complete,
}
pub(crate) fn thread_items_page_params(
thread_id: ThreadId,
turn_id: Option<&str>,
cursor: Option<String>,
limit: u32,
) -> ThreadItemsListParams {
ThreadItemsListParams {
thread_id: thread_id.to_string(),
turn_id: turn_id.map(str::to_string),
cursor,
limit: Some(limit),
sort_direction: Some(SortDirection::Desc),
}
}
fn advancing_cursor(
current: Option<&str>,
next: Option<String>,
seen_cursors: &mut HashSet<String>,
) -> Option<String> {
if let Some(current) = current {
seen_cursors.insert(current.to_string());
}
next.filter(|next| seen_cursors.insert(next.clone()))
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ThreadHistoryPagination {
pub(super) history_mode: ThreadHistoryMode,
next_turn_cursor: Option<String>,
next_item_cursor: Option<String>,
seen_turn_cursors: HashSet<String>,
seen_item_cursors: HashSet<String>,
loading_older: bool,
}
@@ -76,6 +113,23 @@ impl AppServerSession {
Ok(items)
}
pub(crate) async fn thread_items_page(
&mut self,
thread_id: ThreadId,
turn_id: Option<&str>,
cursor: Option<String>,
limit: u32,
) -> Result<ThreadItemsListResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadItemsList {
request_id,
params: thread_items_page_params(thread_id, turn_id, cursor, limit),
})
.await
.wrap_err("failed to load a bounded thread item page")
}
async fn thread_turns_page(
&mut self,
thread_id: ThreadId,
@@ -104,15 +158,25 @@ impl AppServerSession {
state: &mut ThreadHistoryPagination,
turns: &mut Vec<Turn>,
) -> Result<Vec<ThreadItem>> {
state.next_item_cursor = page.next_cursor;
state.next_item_cursor = advancing_cursor(
state.next_item_cursor.as_deref(),
page.next_cursor,
&mut state.seen_item_cursors,
);
let mut items = Vec::new();
for entry in page.data {
while !turns.iter().any(|turn| turn.id == entry.turn_id) {
let Some(cursor) = state.next_turn_cursor.take() else {
break;
};
let page = self.thread_turns_page(thread_id, Some(cursor)).await?;
state.next_turn_cursor = page.next_cursor;
let page = self
.thread_turns_page(thread_id, Some(cursor.clone()))
.await?;
state.next_turn_cursor = advancing_cursor(
Some(&cursor),
page.next_cursor,
&mut state.seen_turn_cursors,
);
turns.splice(0..0, page.data.into_iter().rev());
}
if let Some(turn) = turns.iter_mut().find(|turn| turn.id == entry.turn_id)
@@ -133,6 +197,7 @@ impl AppServerSession {
turn_cursor: Option<String>,
item_cursor: Option<String>,
config: Option<&Config>,
scope: HistoryHydrationScope,
) -> Result<()> {
let thread_id = ThreadId::from_string(&thread.id)
.wrap_err("invalid thread id in bounded history response")?;
@@ -154,53 +219,57 @@ impl AppServerSession {
next_item_cursor: item_cursor,
..ThreadHistoryPagination::default()
};
let row_budget =
config.and_then(|config| resize_reflow_max_rows(config.terminal_resize_reflow));
let item_budget = if config.is_some() && row_budget.is_none() {
None
} else {
Some(HISTORY_ITEM_PAGE_LIMIT as usize)
};
let width = crossterm::terminal::size()
.map(|(width, _)| width.max(/*other*/ 1))
.unwrap_or(/*default*/ 80);
let row_budget =
config.and_then(|config| resize_reflow_max_rows(config.terminal_resize_reflow));
let item_budget = match (scope, config, row_budget) {
(HistoryHydrationScope::Complete, _, _)
| (HistoryHydrationScope::Initial, Some(_), None) => None,
(HistoryHydrationScope::Initial, Some(_), Some(max_rows)) => {
Some(max_rows.saturating_add(HISTORY_ITEM_SCAN_LIMIT))
}
(HistoryHydrationScope::Initial, None, _) => Some(HISTORY_ITEM_PAGE_LIMIT as usize),
};
let mut scanned_items = 0;
let mut rendered_rows = 0;
loop {
let remaining_rows = row_budget.map(|budget| {
budget.saturating_sub(config.map_or(/*default*/ 0, |config| {
rendered_history_rows(thread, config, width)
}))
});
let remaining_rows = row_budget.map(|budget| budget.saturating_sub(rendered_rows));
let remaining_items = item_budget.map(|budget| budget.saturating_sub(scanned_items));
if remaining_rows == Some(0) || remaining_items == Some(0) {
break;
}
let limit = remaining_rows
let limit = remaining_items
.unwrap_or(HISTORY_ITEM_PAGE_LIMIT as usize)
.min(remaining_items.unwrap_or(HISTORY_ITEM_PAGE_LIMIT as usize))
.min(HISTORY_ITEM_PAGE_LIMIT as usize) as u32;
let request_id = self.next_request_id();
let page: ThreadItemsListResponse = self
.client
.request_typed(ClientRequest::ThreadItemsList {
request_id,
params: ThreadItemsListParams {
thread_id: thread_id.to_string(),
turn_id: None,
cursor: state.next_item_cursor.clone(),
limit: Some(limit),
sort_direction: Some(SortDirection::Desc),
},
})
.await
.wrap_err("failed to load a bounded thread item page")?;
.min(HISTORY_ITEM_PAGE_LIMIT as usize);
let limit = if rendered_rows == 0 && scanned_items != 0 {
limit
} else {
limit.min(remaining_rows.unwrap_or(limit))
} as u32;
let page = self
.thread_items_page(
thread_id,
/*turn_id*/ None,
state.next_item_cursor.clone(),
limit,
)
.await?;
if page.data.is_empty() {
state.next_item_cursor = None;
break;
}
scanned_items = scanned_items.saturating_add(page.data.len());
self.merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns)
let items = self
.merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns)
.await?;
if let Some(config) = config {
rendered_rows =
rendered_history_rows(thread_id, thread, items, config, width, rendered_rows);
} else {
rendered_rows = rendered_rows.saturating_add(items.len());
}
if state.next_item_cursor.is_none() {
break;
}
@@ -212,10 +281,14 @@ impl AppServerSession {
}
}
fn rendered_history_rows(thread: &Thread, config: &Config, width: u16) -> usize {
if thread.turns.iter().all(|turn| turn.items.is_empty()) {
return 0;
}
fn rendered_history_rows(
thread_id: ThreadId,
thread: &Thread,
items: Vec<ThreadItem>,
config: &Config,
width: u16,
rendered_rows: usize,
) -> usize {
let visibility = if config.show_raw_agent_reasoning {
RawReasoningVisibility::Visible
} else {
@@ -226,14 +299,20 @@ fn rendered_history_rows(thread: &Thread, config: &Config, width: u16) -> usize
} else {
HistoryRenderMode::Rich
};
thread_to_transcript_cells(
thread.clone(),
thread_items_to_transcript_cells(
Some(thread_id),
&thread.cwd,
items,
visibility,
Some(config.codex_home.as_path()),
)
.into_iter()
.fold(/*init*/ 0, |rows, cell| {
.fold(rendered_rows, |rows, cell| {
let height = usize::from(cell.desired_height_for_mode(width, mode));
rows + height + usize::from(height != 0 && rows != 0 && !cell.is_stream_continuation())
})
}
#[cfg(test)]
#[path = "history_tests.rs"]
mod tests;
@@ -0,0 +1,28 @@
use super::advancing_cursor;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
#[test]
fn advancing_cursor_rejects_repeated_cursors() {
let mut seen_cursors = HashSet::new();
assert_eq!(
advancing_cursor(
/*current*/ None,
Some("first".to_string()),
&mut seen_cursors,
),
Some("first".to_string())
);
assert_eq!(
advancing_cursor(Some("first"), Some("second".to_string()), &mut seen_cursors,),
Some("second".to_string())
);
assert_eq!(
advancing_cursor(Some("second"), Some("first".to_string()), &mut seen_cursors,),
None
);
assert_eq!(
advancing_cursor(Some("second"), /*next*/ None, &mut seen_cursors),
None
);
}
+117
View File
@@ -3239,6 +3239,123 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn resume_picker_loads_complete_paginated_and_legacy_transcripts()
-> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.terminal_resize_reflow.max_rows =
crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(2);
let mut app_server = AppServerSession::new(
AppServerClient::InProcess(start_test_embedded_app_server(config.clone()).await?),
ThreadParamsMode::Embedded,
);
let filename_ts = "2025-01-05T12-00-00";
let rollout_line = |ordinal: usize, payload: serde_json::Value| {
serde_json::json!({
"timestamp": "2025-01-05T12:00:00Z",
"type": "event_msg",
"payload": payload,
"ordinal": ordinal,
})
};
for (history_mode, create_rollout) in [
app_test_support::create_fake_rollout,
app_test_support::create_fake_paginated_rollout,
]
.into_iter()
.enumerate()
{
let thread_id = create_rollout(
temp_dir.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"message 0",
Some(config.model_provider_id.as_str()),
/*git_info*/ None,
)
.expect("create session rollout");
let path = app_test_support::rollout_path(temp_dir.path(), filename_ts, &thread_id);
let mut contents = std::fs::read_to_string(&path)?;
let started = rollout_line(
/*ordinal*/ 3,
serde_json::json!({ "type": "task_started", "turn_id": "history-turn", "model_context_window": null }),
);
contents.push_str(&format!("{started}\n"));
for index in 0..=100 {
let message = format!("message {index}");
let payload = if history_mode == 1 {
serde_json::json!({
"type": "item_completed",
"thread_id": thread_id,
"turn_id": "history-turn",
"item": { "type": "UserMessage", "id": format!("user-{index}"),
"content": [{ "type": "text", "text": message }] },
})
} else {
serde_json::json!({ "type": "user_message", "message": message })
};
let item = rollout_line(index + 4, payload);
contents.push_str(&format!("{item}\n"));
}
if history_mode == 1 {
for index in 0..125 {
let item = rollout_line(
index + 105,
serde_json::json!({
"type": "item_completed",
"thread_id": thread_id,
"turn_id": "history-turn",
"item": { "type": "Reasoning", "id": format!("hidden-{index}"),
"summary_text": [], "raw_content": [] },
}),
);
contents.push_str(&format!("{item}\n"));
}
}
std::fs::write(path, contents)?;
let thread_id = ThreadId::from_string(&thread_id)?;
let started = app_server
.resume_thread(
config.clone(),
thread_id,
app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
if history_mode == 1 {
assert!(
started
.turns
.iter()
.flat_map(|turn| &turn.items)
.any(|item| {
matches!(
item,
codex_app_server_protocol::ThreadItem::UserMessage { .. }
)
})
);
let preview = crate::resume_picker::load_transcript_preview(
&mut app_server,
thread_id,
/*codex_home*/ None,
)
.await?;
assert!(!preview.is_empty());
}
let cells = crate::thread_transcript::load_session_transcript(
&mut app_server,
thread_id,
crate::thread_transcript::RawReasoningVisibility::Hidden,
/*codex_home*/ None,
)
.await?;
assert!(cells.len() > 100);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> {
Box::pin(async {
+228 -69
View File
@@ -5,6 +5,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
use crate::app_server_session::HISTORY_ITEM_SCAN_LIMIT;
use crate::clipboard_paste::normalize_pasted_search_query;
use crate::color::blend;
use crate::color::is_light;
@@ -37,6 +39,7 @@ use crate::wrapping::adaptive_wrap_lines;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
@@ -62,6 +65,7 @@ use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Widget;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::warn;
@@ -159,8 +163,13 @@ struct PageLoadRequest {
enum PickerLoadRequest {
Page(PageLoadRequest),
Preview { thread_id: ThreadId },
Transcript { thread_id: ThreadId },
Preview {
thread_id: ThreadId,
},
Transcript {
thread_id: ThreadId,
cancellation: oneshot::Receiver<()>,
},
}
#[derive(Clone)]
@@ -620,18 +629,24 @@ fn spawn_app_server_page_loader(
.await;
let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview });
}
PickerLoadRequest::Transcript { thread_id } => {
let transcript = load_session_transcript(
&mut app_server,
thread_id,
raw_reasoning_visibility,
codex_home.as_deref(),
)
.await;
let _ = bg_tx.send(BackgroundEvent::Transcript {
thread_id,
transcript,
});
PickerLoadRequest::Transcript {
thread_id,
cancellation,
} => {
tokio::select! {
transcript = load_session_transcript(
&mut app_server,
thread_id,
raw_reasoning_visibility,
codex_home.as_deref(),
) => {
let _ = bg_tx.send(BackgroundEvent::Transcript {
thread_id,
transcript,
});
}
_ = cancellation => {}
}
}
}
}
@@ -706,6 +721,7 @@ struct PickerState {
transcript_previews: HashMap<ThreadId, TranscriptPreviewState>,
transcript_cells: HashMap<ThreadId, SessionTranscriptState>,
pending_transcript_open: Option<ThreadId>,
pending_transcript_cancellation: Option<oneshot::Sender<()>>,
transcript_loading_frame_shown: bool,
overlay: Option<Overlay>,
pager_keymap: PagerKeymap,
@@ -735,7 +751,7 @@ enum SessionTranscriptState {
}
#[derive(Clone)]
struct TranscriptPreviewLine {
pub(crate) struct TranscriptPreviewLine {
speaker: TranscriptPreviewSpeaker,
text: String,
}
@@ -785,81 +801,145 @@ async fn load_app_server_page(
})
}
async fn load_transcript_preview(
pub(crate) async fn load_transcript_preview(
app_server: &mut AppServerSession,
thread_id: ThreadId,
codex_home: Option<&Path>,
) -> std::io::Result<Vec<TranscriptPreviewLine>> {
const MAX_PREVIEW_LINES: usize = 6;
let thread = app_server
.thread_read(thread_id, /*include_turns*/ true)
let mut thread = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
.map_err(std::io::Error::other)?;
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
.map_err(std::io::Error::other)?;
}
let cwd = thread.cwd.as_path();
let inline_visualization_context = codex_home.and_then(|codex_home| {
ThreadId::from_string(&thread.id)
.ok()
.and_then(|thread_id| InlineVisualizationContext::new(codex_home, thread_id))
});
let mut lines = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.filter_map(|item| match item {
ThreadItem::UserMessage { content, .. } => Some(TranscriptPreviewLine {
speaker: TranscriptPreviewSpeaker::User,
text: content
.iter()
.filter_map(|input| match input {
codex_app_server_protocol::UserInput::Text { text, .. } => {
Some(text.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join(" "),
}),
ThreadItem::AgentMessage { text, .. } => {
let visible_markdown = parse_assistant_markdown(text, cwd).visible_markdown;
let rewritten = crate::inline_visualization::rewrite_inline_visualizations(
&visible_markdown,
let mut lines = if thread.history_mode == ThreadHistoryMode::Paginated {
let mut groups = Vec::new();
let mut visible_lines = 0_usize;
let mut scanned_items = 0_usize;
let mut cursor = None;
let mut seen_cursors = HashSet::new();
loop {
let page = app_server
.thread_items_page(
thread_id,
/*turn_id*/ None,
cursor.clone(),
HISTORY_ITEM_PAGE_LIMIT,
)
.await
.map_err(std::io::Error::other)?;
scanned_items = scanned_items.saturating_add(page.data.len());
for entry in page.data {
let item_lines = transcript_preview_lines_for_item(
&entry.item,
cwd,
inline_visualization_context.as_ref(),
);
let mut text = rewritten.markdown.into_owned();
for (placeholder, link) in &rewritten.trusted_file_links {
text = text.replace(
&format!(
"{} \n[{}]({placeholder})",
link.markdown_label, link.markdown_destination_label
),
&format!("{} \n{}", link.display_label, link.destination),
);
visible_lines = visible_lines.saturating_add(item_lines.len());
if !item_lines.is_empty() {
groups.push(item_lines);
}
if visible_lines >= MAX_PREVIEW_LINES {
break;
}
Some(TranscriptPreviewLine {
speaker: TranscriptPreviewSpeaker::Assistant,
text,
})
}
_ => None,
})
.flat_map(|line| {
line.text
.lines()
.filter(|text| !text.trim().is_empty())
.map(move |text| TranscriptPreviewLine {
speaker: line.speaker,
text: text.trim().to_string(),
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
if visible_lines >= MAX_PREVIEW_LINES || scanned_items >= HISTORY_ITEM_SCAN_LIMIT {
break;
}
let Some(next_cursor) = page
.next_cursor
.filter(|next| seen_cursors.insert(next.clone()))
else {
break;
};
cursor = Some(next_cursor);
}
groups.into_iter().rev().flatten().collect::<Vec<_>>()
} else {
thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.flat_map(|item| {
transcript_preview_lines_for_item(item, cwd, inline_visualization_context.as_ref())
})
.collect::<Vec<_>>()
};
if lines.len() > MAX_PREVIEW_LINES {
lines.drain(..lines.len() - MAX_PREVIEW_LINES);
}
Ok(lines)
}
fn transcript_preview_lines_for_item(
item: &ThreadItem,
cwd: &Path,
inline_visualization_context: Option<&InlineVisualizationContext>,
) -> Vec<TranscriptPreviewLine> {
let line = match item {
ThreadItem::UserMessage { content, .. } => TranscriptPreviewLine {
speaker: TranscriptPreviewSpeaker::User,
text: content
.iter()
.filter_map(|input| match input {
codex_app_server_protocol::UserInput::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" "),
},
ThreadItem::AgentMessage { text, .. } => {
let visible_markdown = parse_assistant_markdown(text, cwd).visible_markdown;
let rewritten = crate::inline_visualization::rewrite_inline_visualizations(
&visible_markdown,
inline_visualization_context,
);
let mut text = rewritten.markdown.into_owned();
for (placeholder, link) in &rewritten.trusted_file_links {
text = text.replace(
&format!(
"{} \n[{}]({placeholder})",
link.markdown_label, link.markdown_destination_label
),
&format!("{} \n{}", link.display_label, link.destination),
);
}
TranscriptPreviewLine {
speaker: TranscriptPreviewSpeaker::Assistant,
text,
}
}
_ => return Vec::new(),
};
line.text
.lines()
.filter(|text| !text.trim().is_empty())
.map(|text| TranscriptPreviewLine {
speaker: line.speaker,
text: text.trim().to_string(),
})
.collect()
}
impl SearchState {
fn active_token(&self) -> Option<usize> {
match self {
@@ -978,6 +1058,7 @@ impl PickerState {
transcript_previews: HashMap::new(),
transcript_cells: HashMap::new(),
pending_transcript_open: None,
pending_transcript_cancellation: None,
transcript_loading_frame_shown: false,
overlay: None,
pager_keymap: RuntimeKeymap::defaults().pager,
@@ -1084,7 +1165,12 @@ impl PickerState {
self.transcript_cells
.insert(thread_id, SessionTranscriptState::Loading);
self.begin_transcript_loading(thread_id);
(self.picker_loader)(PickerLoadRequest::Transcript { thread_id });
let (cancellation_tx, cancellation) = oneshot::channel();
self.pending_transcript_cancellation = Some(cancellation_tx);
(self.picker_loader)(PickerLoadRequest::Transcript {
thread_id,
cancellation,
});
}
}
}
@@ -1096,6 +1182,22 @@ impl PickerState {
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit),
key if self.list_keymap.cancel.is_pressed(key) => {
if let Some(thread_id) = self.pending_transcript_open.take()
&& matches!(
self.transcript_cells.get(&thread_id),
Some(SessionTranscriptState::Loading)
)
{
self.transcript_cells.remove(&thread_id);
}
if let Some(cancellation) = self.pending_transcript_cancellation.take() {
let _ = cancellation.send(());
}
self.transcript_loading_frame_shown = false;
self.request_frame();
None
}
_ => None,
}
}
@@ -1404,6 +1506,7 @@ impl PickerState {
self.transcript_cells
.insert(thread_id, SessionTranscriptState::Loaded(cells.clone()));
if should_open {
self.pending_transcript_cancellation = None;
self.open_pending_transcript_if_ready();
}
self.request_frame();
@@ -1412,6 +1515,7 @@ impl PickerState {
self.transcript_cells
.insert(thread_id, SessionTranscriptState::Failed);
if self.pending_transcript_open == Some(thread_id) {
self.pending_transcript_cancellation = None;
self.pending_transcript_open = None;
self.transcript_loading_frame_shown = false;
self.inline_error = Some("Could not load transcript preview".to_string());
@@ -4389,7 +4493,7 @@ mod tests {
let recorded_requests: Arc<Mutex<Vec<ThreadId>>> = Arc::new(Mutex::new(Vec::new()));
let request_sink = recorded_requests.clone();
let loader: PickerLoader = Arc::new(move |request| {
if let PickerLoadRequest::Transcript { thread_id } = request {
if let PickerLoadRequest::Transcript { thread_id, .. } = request {
request_sink.lock().unwrap().push(thread_id);
}
});
@@ -4480,6 +4584,61 @@ mod tests {
assert_eq!(state.query, "");
}
#[tokio::test]
async fn escape_cancels_transcript_loading_and_restores_picker_navigation() {
let thread_id = ThreadId::new();
let cancellation = Arc::new(Mutex::new(None));
let cancellation_sink = cancellation.clone();
let loader: PickerLoader = Arc::new(move |request| {
if let PickerLoadRequest::Transcript { cancellation, .. } = request {
*cancellation_sink.lock().unwrap() = Some(cancellation);
}
});
let mut state = PickerState::new(
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let mut first = make_row("/tmp/1.jsonl", "2026-05-02T12:00:00Z", "one");
first.thread_id = Some(thread_id);
state.filtered_rows = vec![
first,
make_row("/tmp/2.jsonl", "2026-05-02T12:00:00Z", "two"),
];
state
.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL))
.await
.unwrap();
assert_eq!(state.pending_transcript_open, Some(thread_id));
state
.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.pending_transcript_open, None);
assert!(!state.transcript_cells.contains_key(&thread_id));
assert!(
cancellation
.lock()
.unwrap()
.as_mut()
.expect("transcript cancellation receiver")
.try_recv()
.is_ok()
);
state
.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.selected, 1);
}
#[tokio::test]
async fn transcript_loading_still_allows_ctrl_c_exit() {
let loader = page_only_loader(|_| {});
@@ -4571,7 +4730,7 @@ mod tests {
let recorded_requests: Arc<Mutex<Vec<ThreadId>>> = Arc::new(Mutex::new(Vec::new()));
let request_sink = recorded_requests.clone();
let loader: PickerLoader = Arc::new(move |request| {
if let PickerLoadRequest::Transcript { thread_id } = request {
if let PickerLoadRequest::Transcript { thread_id, .. } = request {
request_sink.lock().unwrap().push(thread_id);
}
});
+13 -2
View File
@@ -3,6 +3,7 @@
use std::sync::Arc;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::HistoryHydrationScope;
use crate::git_action_directives::parse_assistant_markdown;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::HistoryCell;
@@ -35,8 +36,18 @@ pub(crate) async fn load_session_transcript(
raw_reasoning_visibility: RawReasoningVisibility,
codex_home: Option<&std::path::Path>,
) -> std::io::Result<TranscriptCells> {
let thread = app_server
.thread_read(thread_id, /*include_turns*/ true)
let mut thread = app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
.map_err(std::io::Error::other)?;
app_server
.hydrate_initial_thread_history(
&mut thread,
/*turn_cursor*/ None,
/*item_cursor*/ None,
/*config*/ None,
HistoryHydrationScope::Complete,
)
.await
.map_err(std::io::Error::other)?;
Ok(thread_to_transcript_cells(
+29
View File
@@ -29,6 +29,7 @@ pub(crate) struct TranscriptReflowState {
last_reflow_width: Option<u16>,
pending_reflow_width: Option<u16>,
pending_until: Option<Instant>,
visible_history_rows: Option<u16>,
ran_during_stream: bool,
resize_requested_during_stream: bool,
}
@@ -43,6 +44,16 @@ impl TranscriptReflowState {
*self = Self::default();
}
/// Cache the history rows left above the composer for the current terminal size.
pub(crate) fn set_visible_history_rows(&mut self, rows: u16) {
self.visible_history_rows = Some(rows.max(/*other*/ 1));
}
/// Return the last viewport budget without querying the terminal during cell replay.
pub(crate) fn visible_history_rows(&self) -> Option<u16> {
self.visible_history_rows
}
/// Record the width observed during a draw and report whether it is new or changed.
///
/// The first observed width initializes the state without scheduling a rebuild because no
@@ -178,6 +189,24 @@ pub(crate) struct TranscriptWidthChange {
mod tests {
use super::*;
#[test]
fn visible_history_rows_are_cached_for_resize_replay() {
let mut state = TranscriptReflowState::default();
state.set_visible_history_rows(/*rows*/ 19);
assert_eq!(state.visible_history_rows(), Some(19));
}
#[test]
fn visible_history_rows_keep_one_row_for_a_full_height_composer() {
let mut state = TranscriptReflowState::default();
state.set_visible_history_rows(/*rows*/ 0);
assert_eq!(state.visible_history_rows(), Some(1));
}
#[test]
fn schedule_debounced_postpones_existing_reflow() {
let mut state = TranscriptReflowState::default();