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