diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 0e9b8f989d..104bbaec09 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -16,6 +16,7 @@ mod provider_selection_tests; mod read_state; mod realtime; mod rollout_history; +pub(crate) use read_state::unread_revision; mod startup_launch; mod thread_list; mod web_search; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 77efc49bc6..b9dc24dd2a 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; use std::sync::Arc; use crate::app_server_session::AppServerSession; +use crate::app_server_session::unread_revision; +use crate::backend_id::BackendId; use crate::clipboard_paste::normalize_pasted_search_query; use crate::clock_format::ClockFormat; use crate::color::blend; @@ -197,6 +199,11 @@ struct PageLoadRequest { } enum PickerLoadRequest { + Acknowledge { + backend_id: crate::backend_id::BackendId, + thread_id: ThreadId, + revision: String, + }, Page(PageLoadRequest), Preview { backend_id: crate::backend_id::BackendId, @@ -338,6 +345,11 @@ struct PickerBackground { } enum BackgroundEvent { + Acknowledged { + backend_id: crate::backend_id::BackendId, + thread_id: ThreadId, + revision: Option, + }, PartialPage { listing_token: usize, page: PickerPage, @@ -357,8 +369,9 @@ enum BackgroundEvent { preview: std::io::Result>, }, Transcript { + backend_id: crate::backend_id::BackendId, thread_id: ThreadId, - transcript: std::io::Result, + transcript: std::io::Result, }, Archive { thread_id: ThreadId, @@ -379,6 +392,7 @@ enum PageCursor { struct PickerPage { rows: Vec, + read_states: HashMap, history_modes: HashMap, next_cursor: Option, num_scanned_files: usize, @@ -836,6 +850,22 @@ fn spawn_app_server_page_loader( page, }); } + PickerLoadRequest::Acknowledge { + thread_id, + revision, + .. + } => match app_server.acknowledge_read(thread_id, revision).await { + Ok(state) => { + let _ = bg_tx.send(BackgroundEvent::Acknowledged { + backend_id, + thread_id, + revision: unread_revision(Some(state)), + }); + } + Err(error) => { + warn!(%thread_id, %error, "Could not acknowledge viewed transcript") + } + }, PickerLoadRequest::Preview { thread_id, .. } => { let preview = tokio::select! { preview = load_transcript_preview(&mut app_server, thread_id, config.as_ref()) => preview, @@ -856,8 +886,10 @@ fn spawn_app_server_page_loader( config.as_ref(), ) => { let _ = bg_tx.send(BackgroundEvent::Transcript { - thread_id, - transcript, + backend_id, thread_id, + transcript: transcript.map(|cells| LoadedTranscript { + cells, unread_revision: app_server.unread_revisions.remove(&thread_id), + }), }); } _ = cancellation => {} @@ -991,8 +1023,10 @@ struct PickerState { archive_state: archive::ArchiveState, expanded_thread_id: Option, transcript_previews: HashMap, - transcript_cells: HashMap, - pending_transcript_open: Option, + transcript_cells: HashMap<(BackendId, ThreadId), SessionTranscriptState>, + pending_transcript_open: Option<(BackendId, ThreadId)>, + unread: HashMap<(BackendId, ThreadId), String>, + overlay_receipt: Option, pending_transcript_cancellation: Option>, transcript_loading_frame_shown: bool, overlay: Option, @@ -1014,9 +1048,14 @@ enum TranscriptPreviewState { Failed, } +struct LoadedTranscript { + cells: TranscriptCells, + unread_revision: Option, +} + enum SessionTranscriptState { Loading, - Loaded(TranscriptCells), + Loaded(LoadedTranscript), Failed, } @@ -1059,6 +1098,7 @@ async fn load_app_server_page( Ok(PickerPage { rows, + read_states: response.read_states.unwrap_or_default(), history_modes, next_cursor: response.next_cursor.map(PageCursor::AppServer), num_scanned_files, @@ -1199,6 +1239,8 @@ impl PickerState { transcript_previews: HashMap::new(), transcript_cells: HashMap::new(), pending_transcript_open: None, + unread: HashMap::new(), + overlay_receipt: None, pending_transcript_cancellation: None, transcript_loading_frame_shown: false, overlay: None, @@ -1261,15 +1303,16 @@ impl PickerState { if !self.transcript_loading_frame_shown { return; } - let Some(thread_id) = self.pending_transcript_open else { + let Some((backend_id, thread_id)) = self.pending_transcript_open else { return; }; - let Some(SessionTranscriptState::Loaded(cells)) = self.transcript_cells.get(&thread_id) + let Some(SessionTranscriptState::Loaded(cells)) = + self.transcript_cells.get(&(backend_id, thread_id)) else { return; }; let mut overlay = Overlay::new_transcript( - cells.clone(), + cells.cells.clone(), self.keymap.pager.clone(), self.copy_on_select, self.mouse_scroll_speed, @@ -1279,23 +1322,36 @@ impl PickerState { view.set_keymap_bindings(&self.keymap); } self.chord_matcher.cancel(); + self.overlay_receipt = + cells + .unread_revision + .clone() + .map(|revision| PickerLoadRequest::Acknowledge { + backend_id, + thread_id, + revision, + }); self.overlay = Some(overlay); self.pending_transcript_open = None; self.transcript_loading_frame_shown = false; self.request_frame(); } - fn begin_transcript_loading(&mut self, thread_id: ThreadId) { - self.pending_transcript_open = Some(thread_id); - self.transcript_loading_frame_shown = false; - self.request_frame(); - } - fn handle_overlay_event(&mut self, tui: &mut Tui, event: TuiEvent) -> Result<()> { let Some(overlay) = &mut self.overlay else { return Ok(()); }; + let draw = matches!( + event, + TuiEvent::Draw | TuiEvent::FocusGained | TuiEvent::Resume | TuiEvent::Resize(_) + ); overlay.handle_event(tui, event)?; + if draw + && tui.is_terminal_focused() + && let Some(receipt) = self.overlay_receipt.take() + { + (self.picker_loader)(receipt); + } if overlay.is_done() { self.overlay = None; self.request_frame(); @@ -1303,6 +1359,19 @@ impl PickerState { Ok(()) } + fn update_unread(&mut self, key: (BackendId, ThreadId), revision: Option) { + if let Some(revision) = revision { + self.unread.insert(key, revision); + } else { + self.unread.remove(&key); + } + } + + fn unread_revision(&self, row: &Row) -> Option<&String> { + self.unread + .get(&(row.backend_id.unwrap_or(self.backend_id), row.thread_id?)) + } + fn open_selected_transcript(&mut self) { let Some(row) = self.filtered_rows.get(self.selected) else { return; @@ -1313,17 +1382,17 @@ impl PickerState { return; }; let backend_id = row.backend_id.unwrap_or(self.backend_id); - match self.transcript_cells.get(&thread_id) { - Some(SessionTranscriptState::Loaded(_)) => { - self.begin_transcript_loading(thread_id); - } - Some(SessionTranscriptState::Loading) => { - self.begin_transcript_loading(thread_id); - } - Some(SessionTranscriptState::Failed) | None => { + let key = (backend_id, thread_id); + self.pending_transcript_open = Some(key); + self.transcript_loading_frame_shown = false; + self.request_frame(); + match self.transcript_cells.get(&key) { + Some(SessionTranscriptState::Loaded(transcript)) + if self.unread.get(&key) == transcript.unread_revision.as_ref() => {} + Some(SessionTranscriptState::Loading) => {} + _ => { self.transcript_cells - .insert(thread_id, SessionTranscriptState::Loading); - self.begin_transcript_loading(thread_id); + .insert(key, SessionTranscriptState::Loading); let (cancellation_tx, cancellation) = oneshot::channel(); self.pending_transcript_cancellation = Some(cancellation_tx); (self.picker_loader)(PickerLoadRequest::Transcript { @@ -1343,13 +1412,13 @@ impl PickerState { .. } if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit), key if self.keymap.list.cancel.is_pressed(key) => { - if let Some(thread_id) = self.pending_transcript_open.take() + if let Some(key) = self.pending_transcript_open.take() && matches!( - self.transcript_cells.get(&thread_id), + self.transcript_cells.get(&key), Some(SessionTranscriptState::Loading) ) { - self.transcript_cells.remove(&thread_id); + self.transcript_cells.remove(&key); } if let Some(cancellation) = self.pending_transcript_cancellation.take() { let _ = cancellation.send(()); @@ -1580,6 +1649,7 @@ impl PickerState { fn start_initial_load(&mut self) { self.relative_time_reference = Some(Utc::now()); self.reset_pagination(); + self.unread.clear(); self.all_rows.clear(); self.filtered_rows.clear(); self.seen_rows.clear(); @@ -1712,33 +1782,47 @@ impl PickerState { ); self.request_frame(); } + BackgroundEvent::Acknowledged { + backend_id, + thread_id, + revision, + } => { + self.update_unread((backend_id, thread_id), revision); + self.request_frame(); + } BackgroundEvent::Transcript { + backend_id, thread_id, transcript, - } => match transcript { - Ok(cells) => { - let should_open = self.pending_transcript_open == Some(thread_id); - self.transcript_cells - .insert(thread_id, SessionTranscriptState::Loaded(cells.clone())); - if should_open { - self.pending_transcript_cancellation = None; + } => { + let key = (backend_id, thread_id); + if self.pending_transcript_open != Some(key) + || !matches!( + self.transcript_cells.get(&key), + Some(SessionTranscriptState::Loading) + ) + { + return Ok(None); + } + self.pending_transcript_cancellation = None; + match transcript { + Ok(cells) => { + self.update_unread(key, cells.unread_revision.clone()); + self.transcript_cells + .insert(key, SessionTranscriptState::Loaded(cells)); self.open_pending_transcript_if_ready(); } - self.request_frame(); - } - Err(_) => { - self.transcript_cells - .insert(thread_id, SessionTranscriptState::Failed); - if self.pending_transcript_open == Some(thread_id) { + Err(_) => { + self.transcript_cells + .insert(key, SessionTranscriptState::Failed); self.chord_matcher.cancel(); - 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()); } - self.request_frame(); } - }, + self.request_frame(); + } BackgroundEvent::Archive { thread_id, result } => { self.handle_archive_result(thread_id, result); } @@ -1764,6 +1848,7 @@ impl PickerState { .map(|target| target.saturating_sub(self.selected)); let PickerPage { rows, + mut read_states, history_modes, next_cursor, num_scanned_files, @@ -1774,6 +1859,10 @@ impl PickerState { for mut row in rows { if let Some(thread_id) = row.thread_id { row.history_mode = history_modes.get(&thread_id).copied(); + self.update_unread( + (row.backend_id.unwrap_or(self.backend_id), thread_id), + unread_revision(read_states.remove(&thread_id.to_string())), + ); } if let Some(cwd) = row.cwd.as_ref() && !self.local_cwd_matches.contains_key(cwd) @@ -3077,11 +3166,6 @@ fn render_comfortable_session_lines( is_zebra: bool, width: u16, ) -> Vec> { - let marker = selection_marker(is_selected, is_expanded); - let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize); - let title = session_title_span(title, row.thread_id, state.use_theme_colors, is_selected); - let title_line = Line::from(vec![marker, title]); - let mut lines = vec![title_line]; let row_style = if is_selected { dense_selected_style() } else if is_zebra { @@ -3089,7 +3173,20 @@ fn render_comfortable_session_lines( } else { Style::default() }; - lines = apply_session_row_background(lines, row_style, width); + let marker = selection_marker(is_selected, is_expanded); + let unread = state.unread_revision(row).is_some(); + let title = truncate_text( + row.display_preview(), + (width as usize).saturating_sub(marker.width() + 2 * usize::from(unread)), + ); + let title = session_title_span(title, row.thread_id, state.use_theme_colors, is_selected); + let mut lines = vec![session_summary_line( + vec![marker], + title, + unread, + row_style, + width, + )]; if is_expanded { lines.extend(render_transcript_preview_lines(row, state, width)); return lines; @@ -3181,6 +3278,7 @@ fn render_dense_session_lines( marker, date: &date, title: row.display_preview(), + unread: state.unread_revision(row).is_some(), thread_id: row.thread_id, use_theme_colors: state.use_theme_colors, is_selected, @@ -3194,6 +3292,7 @@ fn render_dense_session_lines( } struct DenseSummaryInput<'a> { + unread: bool, thread_id: Option, use_theme_colors: bool, marker: Span<'static>, @@ -3205,11 +3304,23 @@ struct DenseSummaryInput<'a> { } fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { + let row_style = if input.is_selected { + dense_selected_style() + } else if input.is_zebra { + dense_zebra_style() + } else { + Style::default() + }; let marker_width = input.marker.width(); let available = (input.width as usize).saturating_sub(marker_width); let columns = dense_columns(available); let title = session_title_span( - dense_column_text(input.title, columns.title_width), + dense_column_text( + input.title, + columns + .title_width + .saturating_sub(2 * usize::from(input.unread)), + ), input.thread_id, input.use_theme_colors, input.is_selected, @@ -3218,17 +3329,32 @@ fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { let spans = vec![ input.marker, dense_column_text(input.date, columns.date_width).set_style(secondary_text_style()), - title, ]; - let line = Line::from(spans); - let row_style = if input.is_selected { - dense_selected_style() - } else if input.is_zebra { - dense_zebra_style() - } else { - Style::default() - }; - apply_line_background(line, row_style, input.width) + session_summary_line(spans, title, input.unread, row_style, input.width) +} + +/// Paint the title and metadata first so contrast adjustment preserves the fixed blue dot. +fn session_summary_line( + mut prefix: Vec>, + title: Span<'static>, + unread: bool, + row_style: Style, + width: u16, +) -> Line<'static> { + let title_index = prefix.len(); + prefix.push(title); + let mut line = apply_line_background( + prefix.into(), + row_style, + width.saturating_sub(2 * u16::from(unread)), + ); + if unread { + line.spans.splice( + title_index..title_index, + [crate::style::task_marker(unread, row_style), " ".into()], + ); + } + line } struct DenseColumns { @@ -3844,6 +3970,7 @@ mod tests { ) -> PickerPage { PickerPage { rows, + read_states: HashMap::new(), history_modes: HashMap::new(), next_cursor: next_cursor.map(|cursor| PageCursor::AppServer(cursor.to_string())), num_scanned_files, @@ -4731,6 +4858,7 @@ mod tests { ); let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + let unread_id = ThreadId::from_u128(/*value*/ 2); let mut rows = [ Row { backend_id: None, @@ -4749,7 +4877,7 @@ mod tests { history_mode: None, path: Some(PathBuf::from("/tmp/b.jsonl")), preview: String::from("Investigate lazy pagination cap"), - thread_id: None, + thread_id: Some(unread_id), thread_name: None, created_at: Some(now - Duration::hours(1)), updated_at: Some(now - Duration::minutes(35)), @@ -4775,12 +4903,20 @@ mod tests { for row in &mut rows[1..] { row.backend_id = Some(local_backend); } - state.ingest_page(page( + let mut local_page = page( rows[1..].to_vec(), /*next_cursor*/ None, /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, - )); + ); + local_page.read_states.insert( + unread_id.to_string(), + codex_app_server_protocol::ThreadReadState { + first_unread: Some(codex_app_server_protocol::ThreadUnreadPosition::ThreadStart), + revision: "revision".into(), + }, + ); + state.ingest_page(local_page); state.selected = 0; state.ingest_page(page( vec![rows[0].clone()], @@ -5032,7 +5168,7 @@ mod tests { /*filter_cwd*/ None, SessionPickerAction::Resume, ); - state.pending_transcript_open = Some(ThreadId::new()); + state.pending_transcript_open = Some((state.backend_id, ThreadId::new())); let rendered = footer_lines_text(&state, /*width*/ 80); @@ -5249,9 +5385,10 @@ mod tests { assert_eq!(state.density, SessionListDensity::Comfortable); assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); - assert_eq!(state.pending_transcript_open, Some(thread_id)); + let key = (state.backend_id, thread_id); + assert_eq!(state.pending_transcript_open, Some(key)); assert!(matches!( - state.transcript_cells.get(&thread_id), + state.transcript_cells.get(&key), Some(SessionTranscriptState::Loading) )); } @@ -5295,7 +5432,7 @@ mod tests { git_branch: None, }, ]; - state.pending_transcript_open = Some(thread_id); + state.pending_transcript_open = Some((state.backend_id, thread_id)); let selection = state .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) @@ -5345,7 +5482,8 @@ mod tests { .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) .await .unwrap(); - assert_eq!(state.pending_transcript_open, Some(thread_id)); + let key = (state.backend_id, thread_id); + assert_eq!(state.pending_transcript_open, Some(key)); state .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) @@ -5353,7 +5491,7 @@ mod tests { .unwrap(); assert_eq!(state.pending_transcript_open, None); - assert!(!state.transcript_cells.contains_key(&thread_id)); + assert!(!state.transcript_cells.contains_key(&key)); assert!( cancellation .lock() @@ -5383,7 +5521,7 @@ mod tests { /*filter_cwd*/ None, SessionPickerAction::Resume, ); - state.pending_transcript_open = Some(ThreadId::new()); + state.pending_transcript_open = Some((state.backend_id, ThreadId::new())); let selection = state .handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) @@ -5409,7 +5547,7 @@ mod tests { SessionPickerAction::Resume, ); let thread_id = ThreadId::new(); - state.pending_transcript_open = Some(thread_id); + state.pending_transcript_open = Some((state.backend_id, thread_id)); state.filtered_rows = vec![ Row { backend_id: None, @@ -5543,11 +5681,11 @@ mod tests { use crate::history_cell::PlainHistoryCell; let thread_id = ThreadId::new(); - let loader = page_only_loader(|_| {}); + let (tx, mut receipts) = tokio::sync::mpsc::unbounded_channel(); let mut state = PickerState::new( crate::backend_id::BackendId::new(), FrameRequester::test_dummy(), - loader, + Arc::new(move |request| tx.send(request).unwrap()), ProviderFilter::MatchDefault(String::from("openai")), /*show_all*/ true, /*filter_cwd*/ None, @@ -5561,23 +5699,32 @@ mod tests { .unwrap(), ) .unwrap(); - state.pending_transcript_open = Some(thread_id); + let owner = BackendId::new(); + let key = (owner, thread_id); + state.pending_transcript_open = Some(key); let cells: TranscriptCells = vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))]; + state + .transcript_cells + .insert(key, SessionTranscriptState::Loading); state .handle_background_event(BackgroundEvent::Transcript { + backend_id: owner, thread_id, - transcript: Ok(cells), + transcript: Ok(LoadedTranscript { + cells, + unread_revision: Some("observed".into()), + }), }) .await .unwrap(); assert!(state.overlay.is_none()); - assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert_eq!(state.pending_transcript_open, Some(key)); assert!(matches!( - state.transcript_cells.get(&thread_id), - Some(SessionTranscriptState::Loaded(_)) + state.transcript_cells.get(&key), + Some(SessionTranscriptState::Loaded(..)) )); assert!(state.note_transcript_loading_frame_drawn()); @@ -5595,6 +5742,16 @@ mod tests { assert_eq!(state.route_key_chord(select), Some(select)); assert!(!state.chord_matcher.is_pending()); let mut tui = crate::tui::test_support::make_test_tui().expect("tui"); + assert!(receipts.try_recv().is_err()); + tui.defer_screen_size(Size::new(80, 24)); + tui.screen_size_for_event(&TuiEvent::Draw).unwrap(); + state + .handle_overlay_event(&mut tui, TuiEvent::Draw) + .unwrap(); + assert!( + matches!(receipts.try_recv(), Ok(PickerLoadRequest::Acknowledge { backend_id, thread_id: id, revision }) + if backend_id == owner && id == thread_id && revision == "observed") + ); state .handle_overlay_event( &mut tui, @@ -5671,11 +5828,13 @@ mod tests { cwd: None, git_branch: None, }]; + let key = (state.backend_id, thread_id); state.transcript_cells.insert( - thread_id, - SessionTranscriptState::Loaded(vec![Arc::new(PlainHistoryCell::new(vec![ - "transcript".into(), - ]))]), + key, + SessionTranscriptState::Loaded(LoadedTranscript { + cells: vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))], + unread_revision: None, + }), ); state @@ -5684,7 +5843,7 @@ mod tests { .unwrap(); assert!(state.overlay.is_none()); - assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert_eq!(state.pending_transcript_open, Some(key)); assert!(state.note_transcript_loading_frame_drawn()); state.open_pending_transcript_if_ready(); @@ -6068,6 +6227,7 @@ session_picker_view = "dense" #[test] fn dense_selected_summary_line_uses_full_width_selection_style() { let line = dense_summary_line(DenseSummaryInput { + unread: false, marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false), date: "15m ago", title: "Selected dense row", @@ -6086,6 +6246,7 @@ session_picker_view = "dense" #[test] fn dense_zebra_summary_line_uses_full_width_background() { let line = dense_summary_line(DenseSummaryInput { + unread: false, marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false), date: "15m ago", title: "Zebra dense row", @@ -7198,7 +7359,7 @@ session_picker_view = "dense" use crate::thread_transcript::thread_to_transcript_cells; let thread_id = ThreadId::new(); - let thread = Thread { + let mut thread = Thread { originator: None, environments: None, id: thread_id.to_string(), @@ -7264,6 +7425,18 @@ session_picker_view = "dense" }], }; + thread.turns.push(codex_app_server_protocol::Turn { + id: String::from("turn-2"), + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::Failed, + error: Some(codex_app_server_protocol::TurnError { + message: String::from("Connection lost"), + codex_error_info: None, + additional_details: None, + misalignment: None, + }), + ..thread.turns[0].clone() + }); let rendered = thread_to_transcript_cells( thread, RawReasoningVisibility::Visible, @@ -7279,6 +7452,7 @@ session_picker_view = "dense" assert!(rendered.contains("hello from assistant")); assert!(rendered.contains("Proposed Plan")); assert!(rendered.contains("Do the thing")); + insta::assert_snapshot!(rendered.lines().last().unwrap(), @"■ Connection lost"); } #[test] diff --git a/codex-rs/tui/src/resume_picker/archive.rs b/codex-rs/tui/src/resume_picker/archive.rs index cdc81addb5..aa5aa31099 100644 --- a/codex-rs/tui/src/resume_picker/archive.rs +++ b/codex-rs/tui/src/resume_picker/archive.rs @@ -107,11 +107,14 @@ impl PickerState { // Keep the seen-row key as a tombstone so an older page that is already // in flight cannot put the archived session back into the picker. self.transcript_previews.remove(&thread_id); - self.transcript_cells.remove(&thread_id); + self.transcript_cells.retain(|(_, id), _| *id != thread_id); if self.expanded_thread_id == Some(thread_id) { self.expanded_thread_id = None; } - if self.pending_transcript_open == Some(thread_id) { + if self + .pending_transcript_open + .is_some_and(|(_, id)| id == thread_id) + { self.pending_transcript_open = None; if let Some(cancellation) = self.pending_transcript_cancellation.take() { let _ = cancellation.send(()); diff --git a/codex-rs/tui/src/resume_picker/sources.rs b/codex-rs/tui/src/resume_picker/sources.rs index 243d96af68..29ab4685cb 100644 --- a/codex-rs/tui/src/resume_picker/sources.rs +++ b/codex-rs/tui/src/resume_picker/sources.rs @@ -16,6 +16,7 @@ struct PendingPage { fn completion_page(catalog: &catalog::ThreadCatalog, failed: &HashSet) -> PickerPage { PickerPage { rows: Vec::new(), + read_states: HashMap::new(), history_modes: HashMap::new(), next_cursor: catalog.next_cursor(failed), num_scanned_files: 0, @@ -109,6 +110,7 @@ pub(super) fn spawn( request => { let backend = match &request { PickerLoadRequest::Preview { backend_id, .. } + | PickerLoadRequest::Acknowledge { backend_id, .. } | PickerLoadRequest::Transcript { backend_id, .. } | PickerLoadRequest::Archive { backend_id, .. } | PickerLoadRequest::Unarchive { backend_id, .. } => *backend_id, @@ -120,7 +122,8 @@ pub(super) fn spawn( let error = std::io::Error::other("The session's backend is unavailable."); let event = match request { PickerLoadRequest::Preview { thread_id, .. } => BackgroundEvent::Preview { thread_id, preview: Err(error) }, - PickerLoadRequest::Transcript { thread_id, .. } => BackgroundEvent::Transcript { thread_id, transcript: Err(error) }, + PickerLoadRequest::Acknowledge { .. } => continue, + PickerLoadRequest::Transcript { thread_id, .. } => BackgroundEvent::Transcript { backend_id: backend, thread_id, transcript: Err(error) }, PickerLoadRequest::Archive { thread_id, .. } => BackgroundEvent::Archive { thread_id, result: Err(error) }, PickerLoadRequest::Unarchive { thread_id, .. } => BackgroundEvent::Unarchive { thread_id, result: Err(error) }, PickerLoadRequest::Page(_) => unreachable!(), diff --git a/codex-rs/tui/src/resume_picker_color_tests.rs b/codex-rs/tui/src/resume_picker_color_tests.rs index 0de3f941a0..a70519a2c4 100644 --- a/codex-rs/tui/src/resume_picker_color_tests.rs +++ b/codex-rs/tui/src/resume_picker_color_tests.rs @@ -35,6 +35,9 @@ fn thread_colors_and_selection_contrast_in_both_picker_layouts() { /*filter_cwd*/ None, SessionPickerAction::Resume, ); + state + .unread + .insert((state.backend_id, row.thread_id.unwrap()), "rev".into()); let (fg, bg) = ((0, 0, 0), (255, 255, 255)); with_test_default_colors(DefaultColors { fg, bg }, || { for density in [SessionListDensity::Comfortable, SessionListDensity::Dense] { @@ -64,7 +67,9 @@ fn thread_colors_and_selection_contrast_in_both_picker_layouts() { SessionListDensity::Comfortable => 2, SessionListDensity::Dense => 14, }; - let title_cell = &buffer[(title_x, 0)]; + let dot = &buffer[(title_x, 0)]; + assert_eq!((dot.symbol(), dot.fg), ("●", rgb_color((0x3b, 0x82, 0xf6)))); + let title_cell = &buffer[(title_x + 2, 0)]; if use_colors { assert_eq!( title_cell.fg, @@ -235,24 +240,23 @@ fn compact_picker_keeps_metadata_and_labeled_primary_actions() { #[test] fn reversed_selection_overrides_thread_color() { - let line = apply_line_background( - Line::from("Title".fg(rgb_color((150, 50, 170)))), - Style::default() - .fg(Color::Reset) - .bg(Color::Reset) - .reversed(), + let row = Style::default() + .fg(Color::Reset) + .bg(Color::Reset) + .reversed(); + let line = session_summary_line( + vec![], + "Title".fg(rgb_color((150, 50, 170))), + /*unread*/ true, + row, /*width*/ 12, ); - assert_eq!( - ( - line.spans[0].style.fg, - line.spans[0] - .style - .add_modifier - .contains(Modifier::REVERSED) - ), - (Some(Color::Reset), true) - ); + let dot = &line.spans[0]; + let title = &line.spans[2]; + assert_eq!(dot.content, "●"); + assert_eq!(dot.style, row.bg(rgb_color((0x3b, 0x82, 0xf6)))); + assert_eq!(title.style.fg, row.fg); + assert!(title.style.add_modifier.contains(Modifier::REVERSED)); } #[tokio::test] diff --git a/codex-rs/tui/src/resume_picker_pagination_error_tests.rs b/codex-rs/tui/src/resume_picker_pagination_error_tests.rs index 85848e1653..368915dccc 100644 --- a/codex-rs/tui/src/resume_picker_pagination_error_tests.rs +++ b/codex-rs/tui/src/resume_picker_pagination_error_tests.rs @@ -47,6 +47,7 @@ async fn later_page_error_preserves_loaded_sessions() { cwd: None, git_branch: None, }], + read_states: HashMap::new(), history_modes: HashMap::new(), next_cursor: Some(PageCursor::AppServer("next-page".to_string())), num_scanned_files: 1, diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index 85103e4c63..ffba5640ac 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -5,7 +5,7 @@ expression: snapshot Fix resume picker timestamps 42s ago ⌁ no cwd  no branch -› Investigate lazy pagination cap +› ● Investigate lazy pagination cap 35m ago ⌁ no cwd  no branch Explain the codebase diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index b2322a57a2..807d7ac4f0 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -103,13 +103,19 @@ pub(crate) fn thread_to_transcript_cells( .turns .into_iter() .flat_map(|turn| { - thread_items_to_transcript_cells( + let mut cells = thread_items_to_transcript_cells( thread_id, &cwd, turn.items.into_iter().map(|entry| entry.payload), raw_reasoning_visibility, config, - ) + ); + if let Some(error) = turn.error { + cells.push(Arc::new(crate::history_cell::new_error_event( + error.message, + ))); + } + cells }) .collect::(); if cells.is_empty() {