From f1feda2299990ab3678e715fe9d39d75a02f0491 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 19 Sep 2026 23:50:42 +0000 Subject: [PATCH] Integrate the interactive transcript into the alternate-screen TUI (#46733) ## What changed - Render transcript history and live output above the composer when `features.transcript_v2` is enabled and alternate-screen rendering is available. Handle scrolling, selection, copying, links, and older-history pagination in the main view. - Add a clickable return-to-latest control with new-activity hints and temporary clipboard status. Let plain Enter return to latest when the composer is empty. - Reflow retained transcript content during drawing and preserve the launch-selected screen mode across configuration reloads and session transitions, including `--no-alt-screen` restrictions. - Enter the alternate screen with its first synchronized frame, and box CLI startup futures to reduce stack usage during session transitions. ## Testing Add regression coverage for transcript layout, input routing, follow controls, clipboard status, and screen-mode persistence. Add a PTY test for the first alternate-screen frame and clean exit, and extend session-transition coverage through the CLI dispatcher. GitOrigin-RevId: 1daf077826c7744b0f57399d10d159923dc87a97 --- codex-rs/cli/src/main.rs | 3 +- codex-rs/tui/src/app.rs | 43 ++- codex-rs/tui/src/app/activity_groups.rs | 9 + .../src/app/agent_message_consolidation.rs | 12 +- codex-rs/tui/src/app/agents_overview.rs | 7 +- codex-rs/tui/src/app/agents_overview_new.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 4 +- codex-rs/tui/src/app/event_dispatch.rs | 13 +- codex-rs/tui/src/app/history_pagination.rs | 46 ++- codex-rs/tui/src/app/history_ui.rs | 20 +- codex-rs/tui/src/app/input.rs | 57 ++- codex-rs/tui/src/app/owned_transcript.rs | 332 ++++++++++++++++++ .../src/app/owned_transcript_follow_tests.rs | 107 ++++++ .../src/app/owned_transcript_input_tests.rs | 180 ++++++++++ .../tui/src/app/owned_transcript_tests.rs | 309 ++++++++++++++++ codex-rs/tui/src/app/resize_reflow.rs | 68 +++- codex-rs/tui/src/app/resize_reflow_tests.rs | 4 +- codex-rs/tui/src/app/resume_config.rs | 2 +- codex-rs/tui/src/app/session_lifecycle.rs | 8 +- ...__tests__owned_history_loading_footer.snap | 39 ++ ..._tests__owned_transcript_composer_gap.snap | 67 ++++ codex-rs/tui/src/app/startup.rs | 16 +- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 107 ++++-- codex-rs/tui/src/app/tests/key_chords.rs | 93 ++++- .../app/tests/pagination_completion_tests.rs | 19 +- codex-rs/tui/src/app/working_directory.rs | 2 +- codex-rs/tui/src/app_backtrack.rs | 18 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 95 ++--- .../bottom_pane/chat_composer/footer_state.rs | 106 ++++++ .../chat_composer/sparkle_tests.rs | 3 +- codex-rs/tui/src/bottom_pane/mod.rs | 50 ++- codex-rs/tui/src/chatwidget.rs | 28 +- .../src/chatwidget/activity_presentation.rs | 34 ++ codex-rs/tui/src/chatwidget/interaction.rs | 9 + codex-rs/tui/src/chatwidget/rendering.rs | 29 +- codex-rs/tui/src/daemon_startup.rs | 2 +- codex-rs/tui/src/daemon_startup_tests.rs | 4 + codex-rs/tui/src/keymap/chords.rs | 34 +- codex-rs/tui/src/local_settings.rs | 28 ++ codex-rs/tui/src/local_settings_tests.rs | 42 +++ codex-rs/tui/src/session_start.rs | 19 +- codex-rs/tui/src/session_start_tests.rs | 22 +- codex-rs/tui/src/startup_orchestration.rs | 5 +- codex-rs/tui/src/transcript_view.rs | 10 + .../tui/src/transcript_view/composer_gap.rs | 108 ++++++ .../src/transcript_view/composer_gap_tests.rs | 57 +++ .../tui/src/transcript_view/follow_control.rs | 105 ++++++ .../transcript_view/follow_control_tests.rs | 260 ++++++++++++++ codex-rs/tui/src/transcript_view/footer.rs | 2 + codex-rs/tui/src/transcript_view/input.rs | 3 + codex-rs/tui/src/transcript_view/mutations.rs | 8 +- codex-rs/tui/src/transcript_view/selection.rs | 1 + ..._and_does_not_claim_terminal_delivery.snap | 31 ++ ...follow_control__tests__follow_control.snap | 62 ++++ codex-rs/tui/src/transcript_view_tests.rs | 4 + codex-rs/tui/src/tui.rs | 24 ++ codex-rs/tui/tests/suite/focus_palette.rs | 142 +++++++- codex-rs/tui/tests/suite/reconnect.rs | 2 +- codex-rs/tui/tests/suite/resize_reflow.rs | 64 ++-- codex-rs/tui/tests/suite/worktree_stack.rs | 56 ++- 61 files changed, 2791 insertions(+), 246 deletions(-) create mode 100644 codex-rs/tui/src/app/owned_transcript.rs create mode 100644 codex-rs/tui/src/app/owned_transcript_follow_tests.rs create mode 100644 codex-rs/tui/src/app/owned_transcript_input_tests.rs create mode 100644 codex-rs/tui/src/app/owned_transcript_tests.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap create mode 100644 codex-rs/tui/src/chatwidget/activity_presentation.rs create mode 100644 codex-rs/tui/src/transcript_view/composer_gap.rs create mode 100644 codex-rs/tui/src/transcript_view/composer_gap_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/follow_control.rs create mode 100644 codex-rs/tui/src/transcript_view/follow_control_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e58c94fcec..2d9e7ed7f5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1172,7 +1172,8 @@ fn main() -> anyhow::Result<()> { codex_build_info::initialize!(); let remote_control_disabled = codex_app_server::take_remote_control_disabled_env(); arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move { - cli_main(arg0_paths, remote_control_disabled).await?; + // Keep the CLI dispatcher off the runtime's stack while the TUI rebuilds a thread. + Box::pin(cli_main(arg0_paths, remote_control_disabled)).await?; Ok(()) }) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0f0ba76944..8a7b37780f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -197,19 +197,19 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use uuid::Uuid; -mod activity_groups; mod agent_message_consolidation; mod agent_navigation; mod agent_picker; mod agent_status_feed; +#[cfg(any(unix, windows))] mod agents_overview; mod agents_overview_actions; mod agents_overview_details; mod agents_overview_threads; mod agents_overview_usage; mod agents_overview_view; -mod native_history; pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID; +mod activity_groups; mod app_server_event_targets; mod app_server_events; pub(crate) mod app_server_requests; @@ -231,6 +231,8 @@ mod misalignment_policy; mod model_defaults; mod new_session; pub(crate) use new_session::has_launch_setting; +mod native_history; +mod owned_transcript; mod pending_interactive_replay; mod permission_shortcuts; mod pets; @@ -571,6 +573,7 @@ pub(crate) struct App { pub(crate) transcript_cells: Vec>, native_history: native_history::NativeHistory, + pub(crate) transcript_view: crate::transcript_view::TranscriptView, last_rendered_history_tail: Option, last_thread_usage_status_cell: Option, pub(crate) pending_thread_usage_history_refresh: bool, @@ -849,9 +852,20 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { + let transcript_owns_input = match (&event, &self.overlay) { + (TuiEvent::Key(key), Some(Overlay::Transcript(overlay))) => { + overlay.owns_interaction_key(*key) + } + (TuiEvent::Key(key), None) => { + tui.is_owned_screen() + && self.chat_widget.no_modal_or_popup_active() + && self.transcript_view.owns_interaction_key(*key) + } + _ => false, + }; if self.reconnect.offline + && !transcript_owns_input && let TuiEvent::Key(key) = &event - && !matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(*key)) && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) && key.modifiers.contains(KeyModifiers::CONTROL) && let KeyCode::Char(character) = key.code @@ -871,9 +885,16 @@ impl App { self.handle_draw_pre_render(tui, screen_size)?; } + if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) { + self.cancel_pending_key_chord(); + } + let event = if let TuiEvent::Key(mut key_event) = event { let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); - if self.should_recover_vim_insert_escape(key_event) { + if self.should_recover_vim_insert_escape(key_event) + && !(tui.is_owned_screen() + && crate::transcript_view::JumpTarget::from_key(key_event).is_some()) + { // Restore both strokes before chords or global shortcuts can consume them. if let Some(escape) = self.route_key_chord_event(tui, escape) { self.handle_key_event(tui, app_server, escape).await; @@ -889,6 +910,9 @@ impl App { event }; + if self.handle_owned_transcript_event(tui, app_server, &event)? { + return Ok(AppRunControl::Continue); + } if self.reconnect.offline && !matches!(&self.overlay, Some(Overlay::Transcript(_))) && let TuiEvent::Key(key) = &event @@ -949,7 +973,7 @@ impl App { } } TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { - if self.backtrack_render_pending { + if self.backtrack_render_pending && !tui.is_owned_screen() { self.rebuild_transcript_after_backtrack(tui, screen_size.into())?; self.backtrack_render_pending = false; } @@ -966,6 +990,12 @@ impl App { self.chat_widget.pre_draw_tick(); self.refresh_agents_overview_usage(app_server, tui.frame_requester()); let rendered_area = self.render_chat_widget_frame(tui, screen_size)?; + if tui.is_owned_screen() + && self.transcript_view.history + != crate::pager_overlay::TranscriptHistoryState::Failed + { + self.request_owned_history(tui, app_server); + } if !had_active_view && self.chat_widget.has_active_view() && self.startup_protected_input_boundary @@ -1022,6 +1052,9 @@ impl App { self.sync_thread_title_progress(); self.chat_widget .set_sparkle_terminal_focus(tui.is_terminal_focused()); + if tui.is_owned_screen() { + return self.render_owned_transcript(tui, screen_size); + } let dashboard_visible = self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) diff --git a/codex-rs/tui/src/app/activity_groups.rs b/codex-rs/tui/src/app/activity_groups.rs index 1102d77c8a..37b7a49e33 100644 --- a/codex-rs/tui/src/app/activity_groups.rs +++ b/codex-rs/tui/src/app/activity_groups.rs @@ -32,6 +32,8 @@ impl App { let range = start..newer_index; self.native_history .consolidate(&self.transcript_cells[range.clone()], &older); + self.transcript_view + .replace_group(&self.transcript_cells, range.clone(), &older); if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { overlay.regroup_cells(range.clone(), Arc::clone(&older)); } @@ -51,6 +53,11 @@ impl App { else { return; }; + self.transcript_view.absorb_tail_into_live( + &self.transcript_cells, + previous_revision, + hydrated_revision, + ); if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { overlay.absorb_tail_into_live(previous_revision, hydrated_revision); } @@ -69,6 +76,8 @@ impl App { let range = start..newer_index + 1; self.native_history .consolidate(&self.transcript_cells[range.clone()], &group); + self.transcript_view + .replace_group(&self.transcript_cells, range.clone(), &group); if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { overlay.regroup_cells(range.clone(), Arc::clone(&group)); } diff --git a/codex-rs/tui/src/app/agent_message_consolidation.rs b/codex-rs/tui/src/app/agent_message_consolidation.rs index c66c863fc0..dfa3a38ea1 100644 --- a/codex-rs/tui/src/app/agent_message_consolidation.rs +++ b/codex-rs/tui/src/app/agent_message_consolidation.rs @@ -40,7 +40,9 @@ impl App { if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_cell(cell.clone()); } - self.native_history.defer(&cell); + if !tui.is_owned_screen() { + self.native_history.defer(&cell); + } self.transcript_cells.push(cell); } @@ -65,6 +67,8 @@ impl App { ); self.native_history .consolidate(&self.transcript_cells[start..end], &consolidated); + self.transcript_view + .replace_range(&self.transcript_cells, start..end, &consolidated); self.transcript_cells .splice(start..end, std::iter::once(consolidated.clone())); @@ -89,6 +93,12 @@ impl App { tui: &mut tui::Tui, scrollback_reflow: ConsolidationScrollbackReflow, ) -> Result<()> { + if tui.is_owned_screen() { + self.transcript_reflow.clear_pending_reflow(); + self.transcript_reflow.clear_stream_flags(); + tui.frame_requester().schedule_frame(); + return Ok(()); + } match scrollback_reflow { ConsolidationScrollbackReflow::IfResizeReflowRan => { self.maybe_finish_stream_reflow(tui)?; diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index db2daa56ea..9fe8fcbcf6 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -421,10 +421,7 @@ impl App { ); let preserve_explicit_permissions = unloaded || started.is_some(); let (mut resume_config, mut local_settings) = if let Some((config, _)) = &started { - ( - config.clone(), - crate::local_settings::LocalSettings::from(config), - ) + (config.clone(), self.local_settings.reloaded(config)) } else if unloaded { let target_session = SessionTarget { path: target_thread.path.clone(), @@ -473,7 +470,7 @@ impl App { { return Ok(control); } - local_settings = crate::local_settings::LocalSettings::from(&resume_config); + local_settings = self.local_settings.reloaded(&resume_config); } // Folder selection and trust prompts can replace or clear the loading frame. loading::draw(tui)?; diff --git a/codex-rs/tui/src/app/agents_overview_new.rs b/codex-rs/tui/src/app/agents_overview_new.rs index 09039f120c..ba1f0f3eaf 100644 --- a/codex-rs/tui/src/app/agents_overview_new.rs +++ b/codex-rs/tui/src/app/agents_overview_new.rs @@ -131,7 +131,7 @@ impl App { display_label: active.id, }) }); - let local_settings = crate::local_settings::LocalSettings::from(&config); + let local_settings = self.local_settings.reloaded(&config); if let Some(draft) = startup_draft.as_deref_mut() { draft.apply_config(&config); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 8346bdd9e1..89d0e4fb38 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -400,7 +400,7 @@ impl App { .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); - self.local_settings = crate::local_settings::LocalSettings::from(&config); + self.local_settings = self.local_settings.reloaded(&config); self.refresh_server_version_overview_notice(CODEX_CLI_VERSION); // Other preferences have runtime caches and are adopted when the widget is replaced. self.chat_widget @@ -452,7 +452,7 @@ impl App { ) -> Result<(Config, crate::local_settings::LocalSettings)> { match self.rebuild_config_for_cwd(resume_cwd.clone()).await { Ok(config) => { - let local_settings = crate::local_settings::LocalSettings::from(&config); + let local_settings = self.local_settings.reloaded(&config); Ok((config, local_settings)) } Err(err) => { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index fff99124c8..b23c52f389 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -299,7 +299,10 @@ impl App { .await { app_server.cancel_older_history_page(thread_id, &cursor); - + if tui.is_owned_screen() && self.chat_widget.thread_id() == Some(thread_id) { + self.transcript_view.history = TranscriptHistoryState::Failed; + tui.frame_requester().schedule_frame(); + } if self.chat_widget.thread_id() == Some(thread_id) && let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { @@ -703,6 +706,7 @@ impl App { if let Some(index) = crate::app_backtrack::nth_user_position(&self.transcript_cells, nth_user_message) { self.transcript_cells.truncate(index); self.native_history.retain(&self.transcript_cells); + self.transcript_view = crate::transcript_view::TranscriptView::default(); } self.deferred_history_lines.clear(); self.last_rendered_history_tail = None; @@ -737,7 +741,9 @@ impl App { .chat_widget .thread_id() .is_some_and(|thread_id| app_server.has_older_history(thread_id)); - + if tui.is_owned_screen() { + self.transcript_view.history = if self.scrollback_has_older_history { TranscriptHistoryState::Partial } else { TranscriptHistoryState::Complete }; + } self.finish_initial_history_replay_buffer(tui); } AppEvent::ConsolidateAgentMessage { @@ -768,6 +774,7 @@ impl App { if start < end { self.native_history.consolidate(&self.transcript_cells[start..end], &consolidated); + self.transcript_view.replace_range(&self.transcript_cells, start..end, &consolidated); self.transcript_cells .splice(start..end, std::iter::once(consolidated.clone())); @@ -778,7 +785,7 @@ impl App { self.finish_required_stream_reflow(tui)?; } else { - let deferred = self.native_history.insert(&consolidated); + let deferred = tui.is_owned_screen() || self.native_history.insert(&consolidated); self.transcript_cells.push(consolidated.clone()); if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_cell(consolidated.clone()); diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index ce3622e3fd..fae90f0a78 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -70,7 +70,9 @@ impl App { if !app_server.is_older_history_page_pending(thread_id, cursor) { return Ok(()); } - if self.chat_widget.thread_id() != Some(thread_id) { + if self.chat_widget.thread_id() != Some(thread_id) + || (tui.is_owned_screen() && !self.scrollback_has_older_history) + { app_server.cancel_older_history_page(thread_id, cursor); return Ok(()); } @@ -120,12 +122,18 @@ impl App { visibility, ); let inserted = self.prepend_older_transcript_cells(cells, width); + self.transcript_view + .history_loaded(&self.transcript_cells, inserted.clone()); if !inserted.is_empty() { self.join_older_activity_group(inserted.end, &turns); } merge_older_turns(&mut store.lock().await.turns, turns); self.scrollback_has_older_history = app_server.has_older_history(thread_id); + if tui.is_owned_screen() { + self.finish_owned_history_page(tui, app_server, thread_id); + return Ok(()); + } self.finish_inline_history_page(tui, app_server, thread_id, width); Ok(()) } @@ -190,6 +198,8 @@ impl App { self.transcript_cells.remove(index); } self.native_history.retain(&self.transcript_cells); + self.transcript_view + .history_loaded(&self.transcript_cells, 0..0); if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { overlay.replace_cells(self.transcript_cells.clone()); } @@ -260,6 +270,40 @@ impl App { inserted } + /// Continue explicit history traversal without rewriting native scrollback. + fn finish_owned_history_page( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) { + let previous = self.transcript_view.history; + self.transcript_view.history = if self.scrollback_has_older_history { + TranscriptHistoryState::Partial + } else { + TranscriptHistoryState::Complete + }; + let continue_to_start = previous == TranscriptHistoryState::LoadingBeginning; + if continue_to_start && !self.scrollback_has_older_history { + self.transcript_view + .jump_to_entry(&self.transcript_cells, /*index*/ 0); + } + if self.scrollback_has_older_history + && (continue_to_start || self.transcript_view.needs_history(&self.transcript_cells)) + && self.request_older_history_page(app_server, thread_id) + { + self.transcript_view.history = if continue_to_start { + TranscriptHistoryState::LoadingBeginning + } else { + TranscriptHistoryState::LoadingOlder + }; + } + if self.backtrack.overlay_preview_active { + self.apply_backtrack_selection_internal(self.backtrack.nth_user_message); + } + tui.frame_requester().schedule_frame(); + } + /// Preserve the legacy overlay and inline scrollback refill behavior. fn finish_inline_history_page( &mut self, diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 82e4897746..70f2c75c14 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -36,7 +36,7 @@ impl App { tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); - let deferred = self.native_history.insert(&cell); + let deferred = tui.is_owned_screen() || self.native_history.insert(&cell); self.render_inserted_history_cell(tui, &cell, deferred); // A committed cell can unblock a settled /usage card that was waiting // behind a transient active cell or a provisional stream tail. @@ -125,6 +125,11 @@ impl App { } pub(crate) fn refresh_thread_usage_history_tail(&mut self, tui: &mut tui::Tui) -> Result<()> { + if tui.is_owned_screen() { + self.pending_thread_usage_history_refresh = false; + tui.frame_requester().schedule_frame(); + return Ok(()); + } let Some(status_history) = self.last_thread_usage_status_cell.as_ref() else { self.pending_thread_usage_history_refresh = false; return Ok(()); @@ -297,6 +302,18 @@ impl App { } pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { + if tui.is_owned_screen() { + if !self.transcript_cells.iter().any(|cell| { + cell.as_any().is::() + || cell.as_any().is::() + }) { + let header: Arc = + Arc::new(self.clear_ui_header_cell(CODEX_CLI_VERSION)); + self.transcript_cells.insert(/*index*/ 0, header); + } + tui.frame_requester().schedule_frame(); + return; + } let width = self .chat_widget .history_wrap_width(tui.terminal.last_known_screen_size.width); @@ -348,6 +365,7 @@ impl App { self.overlay = None; self.transcript_cells.clear(); self.native_history = Default::default(); + self.transcript_view = crate::transcript_view::TranscriptView::default(); self.last_rendered_history_tail = None; self.last_thread_usage_status_cell = None; self.pending_thread_usage_history_refresh = false; diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index dda527f719..cf6b706d69 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -32,7 +32,7 @@ impl App { && (previous_agent_shortcut_matches(key_event, /*allow_word_motion_fallback*/ true) || next_agent_shortcut_matches(key_event, /*allow_word_motion_fallback*/ true))) && !keymap_action_ids() - .filter(|action| active_contexts.contains(action.context)) + .filter(|action| active_contexts.contains_action(*action)) .any(|action| { bindings_for_action(&self.keymap, action.context.config_name(), action.action) .is_some_and(|bindings| bindings.is_pressed(key_event)) @@ -59,6 +59,44 @@ impl App { self.cancel_pending_key_chord(); return Some(key_event); } + if tui.is_owned_screen() + && self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && self.transcript_view.owns_interaction_key(key_event) + && (self.transcript_view.has_active_interaction() + || self.backtrack.overlay_preview_active + || crate::transcript_view::JumpTarget::from_key(key_event).is_none()) + { + let close_chord = tui.is_owned_screen() + && self.overlay.is_none() + && self.transcript_view.is_detailed() + && !self.transcript_view.has_active_interaction() + && !self.backtrack.overlay_preview_active + && match self.key_chord_matcher.clone().advance( + key_event, + &self.keymap.chords, + self.active_keymap_contexts(), + tokio::time::Instant::now(), + ) { + crate::keymap::KeyChordMatch::Completed(event) => { + self.keymap.pager.close_transcript.is_pressed(event) + } + crate::keymap::KeyChordMatch::Pending(prefix) => { + self.keymap.chords.bindings.iter().any(|binding| { + binding.action.context == crate::keymap::KeymapContext::Pager + && binding.action.action == "close_transcript" + && binding.chord.prefix == prefix + }) + } + crate::keymap::KeyChordMatch::PassThrough + | crate::keymap::KeyChordMatch::Cancelled + | crate::keymap::KeyChordMatch::Ignored => false, + }; + if !close_chord { + self.cancel_pending_key_chord(); + return Some(key_event); + } + } let contexts = self.active_keymap_contexts(); let was_pending = self.key_chord_matcher.is_pending(); if !was_pending @@ -137,7 +175,7 @@ impl App { self.chat_widget.set_footer_hint_override(items); } - fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + pub(super) fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet { use crate::keymap::KeymapContext; use crate::keymap::KeymapContextSet; @@ -150,6 +188,11 @@ impl App { let contexts = contexts .with(KeymapContext::Global) .with(KeymapContext::Chat); + let contexts = if self.transcript_view.is_detailed() { + contexts.with_transcript_close() + } else { + contexts + }; if voice_available { contexts.with(KeymapContext::Voice) } else { @@ -243,6 +286,14 @@ impl App { } else { self.chat_widget.set_raw_output_mode(enabled); } + if tui.is_owned_screen() { + self.transcript_view.set_presentation( + self.transcript_view.is_detailed(), + self.chat_widget.history_render_mode(), + ); + tui.frame_requester().schedule_frame(); + return; + } if self.overlay.is_some() { self.schedule_immediate_resize_reflow(tui); return; @@ -474,7 +525,7 @@ impl App { // with the composer focused and empty. In any other state, forward // Esc so the active UI (e.g. status indicator, modals, popups) // handles it. - if self.should_handle_backtrack_esc(key_event) { + if !tui.is_owned_screen() && self.should_handle_backtrack_esc(key_event) { self.chat_widget.prepare_composer_sparkle_key(key_event); self.handle_backtrack_esc_key(tui); } else if self.should_reject_side_backtrack_esc(key_event) { diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs new file mode 100644 index 0000000000..918e299bc0 --- /dev/null +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -0,0 +1,332 @@ +//! Compose the owned transcript above the existing composer and route transcript-only gestures. +//! Reserve a cleared row between transcript content and the composer. Slash suggestions overlay +//! already-painted rows so opening or closing them leaves transcript geometry unchanged. +//! Fresh-thread decoration is painted separately in unused cells and never enters history. +//! Plain Enter returns an empty composer to latest after transcript interactions and prompt editing. + +use super::*; +use crate::history_cell::HistoryRenderMode; +use crate::keymap::KeymapContext; +use crate::keymap::bindings_for_action; +use crate::keymap::keymap_action_ids; +use crate::motion::MotionMode; +use crate::pager_overlay::TranscriptHistoryState; +use crate::transcript_view::ViewAction; +use ratatui::widgets::Widget; + +impl App { + fn sync_owned_transcript(&mut self, width: u16) -> bool { + let chat_widget = &self.chat_widget; + let transcript_width = chat_widget.history_wrap_width(width); + let view = &mut self.transcript_view; + view.set_presentation(view.is_detailed(), chat_widget.history_render_mode()); + let active_key = chat_widget.active_cell_transcript_key(); + let detailed = view.is_detailed(); + view.sync_live_tail(transcript_width, active_key, |width| { + chat_widget.active_cell_hyperlink_lines_with(width, |cell, width| { + if detailed { + cell.transcript_hyperlink_lines(width) + } else if chat_widget.history_render_mode() == HistoryRenderMode::Raw { + cell.display_hyperlink_lines_for_mode(width, HistoryRenderMode::Raw) + } else { + cell.compact_hyperlink_lines(width) + } + }) + }) + } + + pub(super) fn render_owned_transcript( + &mut self, + tui: &mut tui::Tui, + screen_size: Size, + ) -> Result { + let motion = MotionMode::from_animations_enabled(self.local_settings.tui.animations); + let latest_navigation = if self.enter_returns_to_latest() { + "enter/esc latest" + } else { + "esc latest" + }; + self.sync_owned_transcript(screen_size.width); + let chat_widget = &self.chat_widget; + let transcript_width = chat_widget.history_wrap_width(screen_size.width); + let view = &mut self.transcript_view; + let active_key = chat_widget.active_cell_transcript_key(); + view.sync_history_tail(&self.transcript_cells); + let footer_area = crate::bottom_pane::inset_footer_hint_area(Rect::new( + /*x*/ 0, + /*y*/ 0, + screen_size.width, + /*height*/ 1, + )); + let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation); + let bottom = chat_widget.bottom_pane_renderable(footer.as_ref()); + let dashboard_visible = chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some(); + let bottom_height = if dashboard_visible { + screen_size.height + } else { + bottom + .desired_height(screen_size.width) + .min(screen_size.height) + }; + let available = screen_size.height.saturating_sub(bottom_height); + let bottom_area = Rect::new( + /*x*/ 0, + screen_size.height.saturating_sub(bottom_height), + screen_size.width, + bottom_height, + ); + let mut rendered_cursor = None; + let mut footer_height_changed = false; + let mut feedback_tick = None; + tui.draw(screen_size.height, |frame| { + ratatui::widgets::Clear.render( + Rect::new(/*x*/ 0, /*y*/ 0, screen_size.width, available), + frame.buffer, + ); + view.render( + Rect::new( + /*x*/ 0, + /*y*/ 0, + transcript_width, + available.saturating_sub(/*rhs*/ 1), + ), + frame.buffer, + &self.transcript_cells, + ); + let follow_area = + (available > 0 && chat_widget.no_modal_or_popup_active()).then(|| { + Rect::new( + /*x*/ 0, + available - 1, + transcript_width, + /*height*/ 1, + ) + }); + feedback_tick = view.render_composer_gap(follow_area, /*hint*/ None, frame.buffer); + // Rendering resolves whether new activity is still hidden. Paint that result in + // this frame so a revision change cannot flash a stale activity hint. + let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation); + + let bottom = chat_widget.bottom_pane_renderable(footer.as_ref()); + footer_height_changed = !dashboard_visible + && bottom + .desired_height(screen_size.width) + .min(screen_size.height) + != bottom_height; + bottom.render(bottom_area, frame.buffer); + chat_widget.note_rendered_width(screen_size.width); + rendered_cursor = bottom.cursor_pos(bottom_area); + if let Some(position) = rendered_cursor { + frame.set_cursor_style(bottom.cursor_style(bottom_area)); + frame.set_cursor_position(position); + } + })?; + if footer_height_changed { + tui.frame_requester().schedule_frame(); + } + if let Some(delay) = feedback_tick { + tui.frame_requester().schedule_frame_in(delay); + } + + let animating = + view.is_following() && active_key.is_some_and(|key| key.animation_tick.is_some()); + let loading = view.is_loading_history() && motion == MotionMode::Animated; + if animating || loading { + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(/*millis*/ 50)); + } + Ok(bottom_area) + } + + /// Consume transcript gestures only when a modal or another overlay does not own input. + pub(super) fn handle_owned_transcript_event( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: &TuiEvent, + ) -> Result { + if !tui.is_owned_screen() + || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) + || self.overlay.is_some() + || !self.chat_widget.no_modal_or_popup_active() + { + self.transcript_view.end_drag(); + } + if !tui.is_owned_screen() || self.overlay.is_some() { + return Ok(false); + } + if matches!(event, TuiEvent::FocusLost) { + // Show the static, faded decoration immediately when the terminal loses focus. + tui.frame_requester().schedule_frame(); + } + if self.transcript_view.history == TranscriptHistoryState::Idle { + self.transcript_view.history = if self.scrollback_has_older_history { + TranscriptHistoryState::Partial + } else { + TranscriptHistoryState::Complete + }; + } + if matches!(event, TuiEvent::Draw) { + if self.transcript_view.tick_selection(&self.transcript_cells) { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } + return Ok(false); + } + if !self.chat_widget.no_modal_or_popup_active() { + return Ok(false); + } + let input = matches!(event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) + || matches!(event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved); + if input { + let size = tui.prepare_draw_size()?; + self.render_owned_transcript(tui, size)?; + } + if !self.transcript_view.has_active_interaction() + && self.chat_widget.shortcut_overlay_visible() + && matches!(event, TuiEvent::Key(key) if key.code == KeyCode::Esc) + { + return Ok(false); + } + if let TuiEvent::Key(key) = event + && !self.transcript_view.has_active_interaction() + && self.chat_widget.should_handle_vim_insert_escape(*key) + { + return Ok(false); + } + if let TuiEvent::Key(key) = event + && !self.transcript_view.has_active_interaction() + && !self.backtrack.overlay_preview_active + && self.transcript_view.is_detailed() + && self.keymap.pager.close_transcript.is_pressed(*key) + { + self.close_transcript_overlay(tui); + return Ok(true); + } + if let TuiEvent::Key(key) = event + && ((key.modifiers.is_empty() + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown)) + || crate::transcript_view::JumpTarget::from_key(*key).is_some()) + && !self.transcript_view.has_active_interaction() + && keymap_action_ids().any(|action| { + action.context != KeymapContext::Pager + && self.active_keymap_contexts().contains_action(action) + && bindings_for_action( + &self.keymap, + action.context.config_name(), + action.action, + ) + .is_some_and(|bindings| bindings.is_pressed(*key)) + }) + { + return Ok(false); + } + let action = match event { + TuiEvent::Key(key) => self + .transcript_view + .handle_key(*key, &self.transcript_cells), + TuiEvent::Mouse(mouse) => self + .transcript_view + .handle_mouse(*mouse, &self.transcript_cells), + TuiEvent::Paste(_) => { + self.transcript_view.end_selection(&self.transcript_cells); + None + } + TuiEvent::Draw + | TuiEvent::Resume + | TuiEvent::Resize(_) + | TuiEvent::FocusGained + | TuiEvent::FocusLost => None, + }; + let Some(action) = action else { + if matches!( + event, + TuiEvent::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + }) + ) && self.enter_returns_to_latest() + && self.transcript_view.can_return_to_latest() + { + self.transcript_view.jump_to_latest(); + tui.frame_requester().schedule_frame(); + return Ok(true); + } + return Ok(false); + }; + let resume_following = matches!(action, ViewAction::CopyAndFollow(_)); + match action { + ViewAction::Changed => {} + ViewAction::Copy(text) | ViewAction::CopyAndFollow(text) => { + let result = self.transcript_view.copy_selected_text_with( + &self.transcript_cells, + &text, + |text| tui.copy_transcript_selection(text), + ); + self.transcript_view + .show_copy_feedback(&result, text.chars().count()); + if resume_following + && matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) + { + if self.backtrack.overlay_preview_active { + self.close_transcript_overlay(tui); + } + self.transcript_view.jump_to_latest(); + } + } + ViewAction::OpenLink(url) => self.open_url_in_browser(url), + } + self.request_owned_history(tui, app_server); + tui.frame_requester().schedule_frame(); + Ok(true) + } + + /// Keep the navigation hint and input guard aligned, including pending paste and attachments. + fn enter_returns_to_latest(&self) -> bool { + self.chat_widget.composer_is_empty() + && self.chat_widget.no_modal_or_popup_active() + && !self.backtrack.primed + && !self.backtrack.overlay_preview_active + } + + pub(super) fn request_owned_history( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) { + if !self.scrollback_has_older_history { + return; + } + let view = &mut self.transcript_view; + if !view.needs_history(&self.transcript_cells) + && view.history != TranscriptHistoryState::LoadingBeginning + { + return; + } + if let Some(thread_id) = self.chat_widget.thread_id() + && app_server.has_older_history(thread_id) + && self.request_older_history_page(app_server, thread_id) + { + if self.transcript_view.history != TranscriptHistoryState::LoadingBeginning { + self.transcript_view.history = TranscriptHistoryState::LoadingOlder; + } + tui.frame_requester().schedule_frame(); + } + } +} + +#[cfg(test)] +#[path = "owned_transcript_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "owned_transcript_follow_tests.rs"] +mod follow_tests; + +#[cfg(test)] +#[path = "owned_transcript_input_tests.rs"] +mod input_tests; diff --git a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs new file mode 100644 index 0000000000..b83af5ab36 --- /dev/null +++ b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs @@ -0,0 +1,107 @@ +//! The return control preserves composer geometry and draft across mouse navigation and resize. + +use super::*; +use crate::history_cell::PlainHistoryCell; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.chat_widget + .apply_external_edit("draft stays here".to_string()); + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new( + (0..40) + .map(|row| format!("transcript row {row}").into()) + .collect(), + ))]; + for width in [80, 40] { + let size = Size::new(width, /*height*/ 12); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + app.transcript_view.jump_to_latest(); + let bottom = app.render_owned_transcript(&mut tui, size)?; + let cursor = tui.terminal.last_known_cursor_pos; + app.transcript_view + .scroll(&app.transcript_cells, /*rows*/ -10); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + for height in [24, 12] { + let resized = Size::new(width, height); + tui.screen_size_for_event(&TuiEvent::Resize(resized))?; + let resized_bottom = app.render_owned_transcript(&mut tui, resized)?; + let resized_cursor = tui.terminal.last_known_cursor_pos; + assert_eq!( + ( + app.chat_widget.composer_text_with_pending(), + resized_cursor.x, + resized_cursor.y - resized_bottom.y, + app.transcript_view.is_following(), + ), + ( + "draft stays here".to_string(), + cursor.x, + cursor.y - bottom.y, + false + ) + ); + } + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let row = bottom.y - 1; + let column = (0..width) + .find(|column| buffer[(*column, row)].symbol() == "↓") + .expect("return control"); + for interrupted in [true, false] { + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + if interrupted && kind == MouseEventKind::Up(MouseButton::Left) { + app.handle_owned_transcript_event(&mut tui, &mut server, &TuiEvent::FocusLost)?; + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + app.render_owned_transcript(&mut tui, size)?; + } + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + let handled = app.handle_owned_transcript_event( + &mut tui, + &mut server, + &TuiEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }), + )?; + assert!(handled || interrupted); + } + assert_eq!(app.transcript_view.is_following(), !interrupted); + } + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + assert_eq!( + ( + app.chat_widget.composer_text_with_pending(), + tui.terminal.last_known_cursor_pos + ), + ("draft stays here".to_string(), cursor) + ); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + } + tui.set_owned_screen(/*owned*/ false)?; + assert!(!app.handle_owned_transcript_event( + &mut tui, + &mut server, + &TuiEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 20, + row: 7, + modifiers: KeyModifiers::NONE, + }) + )?); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs new file mode 100644 index 0000000000..9ffaa2bae6 --- /dev/null +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -0,0 +1,180 @@ +//! Composer input resumes after transcript selection without changing search or keymap ownership. + +use super::*; +use crate::history_cell::HistoryRenderMode; +use crate::history_cell::PlainHistoryCell; +use pretty_assertions::assert_eq; + +async fn select_transcript( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + detailed: bool, +) -> Result<()> { + app.transcript_view = Default::default(); + app.transcript_view + .set_presentation(detailed, HistoryRenderMode::Rich); + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![ + "replaced cell".into(), + ]))]; + app.render_owned_transcript(tui, Size::new(/*width*/ 80, /*height*/ 12))?; + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![ + "select this transcript".into(), + ]))]; + app.handle_tui_event( + tui, + app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + ) + .await?; + assert!( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .contains("select this transcript") + ); + for _ in 0..6 { + app.handle_tui_event( + tui, + app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)), + ) + .await?; + } + assert_eq!( + app.transcript_view + .selected_text(&app.transcript_cells) + .as_deref(), + Some("select") + ); + Ok(()) +} + +#[tokio::test] +async fn modified_arrows_and_editor_chords_resume_composer_movement() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let config = serde_json::from_value(serde_json::json!({ + "editor": {"move_line_start": ["home", "ctrl-a", "ctrl-shift-left", "ctrl-x h"]} + }))?; + app.keymap = RuntimeKeymap::from_config(&config).expect("valid editor bindings"); + app.chat_widget.apply_keymap_update(config, &app.keymap); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + for detailed in [false, true] { + for (keys, expected) in [ + ( + vec![KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)], + "alpha beta Xgamma", + ), + ( + vec![KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)], + "alpha beta Xgamma", + ), + ( + vec![ + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), + ], + "alpha beta gammaX", + ), + ( + vec![ + KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + KeyEvent::new(KeyCode::Right, KeyModifiers::ALT), + ], + "alpha beta gammaX", + ), + ( + vec![KeyEvent::new( + KeyCode::Left, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )], + "Xalpha beta gamma", + ), + ( + vec![ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ], + "Xalpha beta gamma", + ), + ( + vec![ + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + ], + "aXlpha beta gamma", + ), + ] { + app.chat_widget + .apply_external_edit("alpha beta gamma".to_string()); + select_transcript(&mut app, &mut tui, &mut app_server, detailed).await?; + for key in keys { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key)) + .await?; + } + assert!(!app.transcript_view.has_active_interaction()); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("X".to_string())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + expected, + "detailed={detailed}" + ); + } + } + app_server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn composer_paste_releases_selection_and_retains_normal_cursor_movement() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = Size::new(/*width*/ 80, /*height*/ 12); + for detailed in [false, true] { + app.chat_widget.apply_external_edit("draft ".to_string()); + select_transcript(&mut app, &mut tui, &mut app_server, detailed).await?; + app.render_owned_transcript(&mut tui, size)?; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Paste("café\r\nbeta\rgamma".to_string()), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "draft café\nbeta\ngamma" + ); + assert!(!app.transcript_view.has_active_interaction()); + assert!(!app.transcript_view.tick_selection(&app.transcript_cells)); + assert!(!app.handle_owned_transcript_event( + &mut tui, + &mut app_server, + &TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + )?); + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ) + .await?; + // A paste inserts immediately, avoiding the terminal's typing-burst timer in this fixture. + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("X".to_string())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "draft café\nbeta\ngammXa" + ); + app.render_owned_transcript(&mut tui, size)?; + } + app_server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs new file mode 100644 index 0000000000..a7be666386 --- /dev/null +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -0,0 +1,309 @@ +//! Owned transcript integration preserves composer spacing/input, prompt editing, and gestures. + +use super::*; +use crate::chatwidget::tests::helpers::normalize_snapshot_paths; +use crate::session_state::ThreadSessionState; +use crate::test_support::test_path_buf; +use codex_app_server_protocol::AskForApproval; +use codex_config::types::ApprovalsReviewer; +use codex_protocol::models::PermissionProfile; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; + +fn attach_thread(app: &mut App, thread_id: ThreadId) { + app.chat_widget.handle_thread_session(ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + collaboration_mode: None, + personality: None, + message_history: None, + network_proxy: None, + rollout_path: None, + }); +} + +fn buffer_text(buffer: &Buffer) -> String { + buffer + .content() + .chunks(usize::from(buffer.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +#[tokio::test] +async fn older_page_loading_uses_the_status_row_without_moving_content_or_cursor() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.local_settings.tui.animations = false; + app.chat_widget.apply_external_edit("draft".to_string()); + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new( + (1..=40) + .map(|row| format!("Transcript row {row:02}").into()) + .collect(), + ))]; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let mut snapshots = Vec::new(); + for width in [80, 40, 28] { + let size = Size::new(width, /*height*/ 12); + tui.terminal.resize(size)?; + app.transcript_view.history = TranscriptHistoryState::Partial; + app.render_owned_transcript(&mut tui, size)?; + app.transcript_view + .scroll(&app.transcript_cells, /*rows*/ -3); + let bottom = app.render_owned_transcript(&mut tui, size)?; + let cursor = tui.terminal.last_known_cursor_pos; + let before = + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone(); + for state in [ + TranscriptHistoryState::LoadingOlder, + TranscriptHistoryState::LoadingBeginning, + TranscriptHistoryState::Failed, + TranscriptHistoryState::Complete, + ] { + app.transcript_view.history = state; + let actual_bottom = app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert_eq!( + (actual_bottom, tui.terminal.last_known_cursor_pos), + (bottom, cursor) + ); + let status_start = buffer.index_of(/*x*/ 0, size.height - 1); + assert_eq!( + &buffer.content()[..status_start], + &before.content()[..status_start] + ); + if state == TranscriptHistoryState::Complete { + assert_eq!( + buffer_text(buffer).lines().last().unwrap().trim(), + "esc latest", + ); + } + if state == TranscriptHistoryState::LoadingOlder { + assert_eq!( + buffer[(2, size.height - 1)].fg, + crate::style::accent_color() + ); + } + let rendered = buffer_text(buffer); + let rendered = rendered.lines().last().unwrap(); + snapshots.push(format!("{width} columns · {state:?}\n{rendered}")); + } + } + insta::assert_snapshot!("owned_history_loading_footer", snapshots.join("\n\n")); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn owned_transcript_reserves_a_row_above_the_composer() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new( + (1..=40) + .map(|row| format!("Transcript row {row:02}").into()) + .collect(), + ))]; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let mut snapshots = Vec::new(); + for (label, width, height, draft) in [ + ("Latest", 80, 12, "draft"), + ("Reading", 80, 12, "draft"), + ( + "Resized multiline composer", + 40, + 12, + "draft\nsecond line\nthird line", + ), + ("Tiny terminal", 40, 5, "draft"), + ("Detailed", 80, 12, "preserved draft"), + ] { + let size = Size::new(width, height); + tui.terminal.resize(size)?; + app.chat_widget.apply_external_edit(draft.to_string()); + if label == "Detailed" { + app.transcript_cells = vec![Arc::new(crate::history_cell::new_view_image_tool_call( + codex_utils_path_uri::LegacyAppPathString::from_string("assets/detail-image.png"), + ))]; + app.open_transcript_overlay(&mut tui); + assert!(app.overlay.is_none() && app.transcript_view.is_detailed()); + } + if label == "Reading" { + app.transcript_view.handle_key( + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + &app.transcript_cells, + ); + } + let bottom = app.render_owned_transcript(&mut tui, size)?; + if bottom.y == 0 { + assert_eq!(label, "Tiny terminal"); + snapshots.push(format!( + "{label}\n{}", + normalize_snapshot_paths(buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) + )) + )); + continue; + } + let gap = Rect::new(/*x*/ 0, bottom.y - 1, width, /*height*/ 1); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let start = buffer.index_of(gap.x, gap.y); + if app.transcript_view.is_following() { + assert_eq!( + &buffer.content()[start..start + usize::from(width)], + Buffer::empty(gap).content(), + "{label}", + ); + } else { + assert!( + buffer_text(buffer) + .lines() + .nth(usize::from(gap.y)) + .unwrap() + .contains("Back to bottom") + ); + } + let rendered = normalize_snapshot_paths(buffer_text(buffer)); + snapshots.push(format!("{label}\n{rendered}")); + assert!( + app.transcript_view + .handle_mouse( + crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down( + crossterm::event::MouseButton::Left + ), + column: gap.x, + row: gap.y, + modifiers: KeyModifiers::NONE, + }, + &app.transcript_cells, + ) + .is_none() + ); + } + insta::assert_snapshot!("owned_transcript_composer_gap", snapshots.join("\n\n")); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn owned_drag_stops_when_focus_or_input_ownership_is_lost() -> Result<()> { + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new( + (0..40).map(|row| format!("row {row:02}").into()).collect(), + ))]; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = Size::new(/*width*/ 40, /*height*/ 12); + for owner in ["focus", "overlay", "popup"] { + app.transcript_view = Default::default(); + app.overlay = None; + app.render_owned_transcript(&mut tui, size)?; + app.transcript_view + .scroll(&app.transcript_cells, /*rows*/ -12); + app.render_owned_transcript(&mut tui, size)?; + for (kind, row) in [ + (MouseEventKind::Down(MouseButton::Left), 2), + (MouseEventKind::Drag(MouseButton::Left), 0), + ] { + app.handle_owned_transcript_event( + &mut tui, + &mut app_server, + &TuiEvent::Mouse(MouseEvent { + kind, + column: 3, + row, + modifiers: KeyModifiers::NONE, + }), + )?; + } + assert!(app.transcript_view.tick_selection(&app.transcript_cells)); + let selected = app.transcript_view.selected_text(&app.transcript_cells); + let event = match owner { + "focus" => TuiEvent::FocusLost, + "overlay" => { + app.overlay = Some(Overlay::new_static_with_lines( + vec!["overlay".into()], + "Overlay".to_string(), + app.keymap.pager.clone(), + )); + TuiEvent::Draw + } + "popup" => { + app.chat_widget.open_feature_enable_prompt(Feature::Collab); + TuiEvent::Draw + } + _ => unreachable!(), + }; + app.handle_owned_transcript_event(&mut tui, &mut app_server, &event)?; + assert!( + !app.transcript_view.tick_selection(&app.transcript_cells), + "{owner}" + ); + assert_eq!( + app.transcript_view.selected_text(&app.transcript_cells), + selected + ); + } + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn owned_transcript_keeps_text_out_of_the_pet_columns() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(vec![ + "x".repeat(/*n*/ 150).into(), + ]))]; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = Size::new(/*width*/ 80, /*height*/ 24); + app.render_owned_transcript(&mut tui, size)?; + app.chat_widget + .set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + app.chat_widget + .install_test_ambient_pet_for_tests(/*animations_enabled*/ false); + let width = app.chat_widget.history_wrap_width(size.width); + assert!(width < size.width); + let bottom = app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + assert!(buffer_text(buffer).contains(&"x".repeat(/*n*/ 60))); + for y in 0..bottom.y { + for x in width..size.width { + assert_eq!(buffer[(x, y)].symbol(), " "); + } + } + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 9e89a55d32..ddb5367825 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -13,6 +13,9 @@ //! The row cap is enforced while rendering from `HistoryCell` source, not after writing to the //! terminal. Initial resume replay uses the same display-line buffering contract so large sessions //! do not write more retained rows than resize replay would later be willing to rebuild. +//! +//! Owned-screen sessions lay out their retained transcript during drawing. Their entry points drop +//! queued scrollback work and request a frame instead of clearing or replaying terminal history. use std::collections::VecDeque; use std::sync::Arc; @@ -107,6 +110,10 @@ impl App { cell: &dyn HistoryCell, width: u16, ) { + if tui.is_owned_screen() { + tui.frame_requester().schedule_frame(); + return; + } let display = self.display_lines_for_history_insert(cell, width); if display.is_empty() { return; @@ -157,6 +164,11 @@ impl App { let Some(buffer) = self.initial_history_replay_buffer.take() else { return; }; + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + tui.frame_requester().schedule_frame(); + return; + } if buffer.render_from_transcript_tail || self.overlay.is_some() { // Reflow clears any pre-replay or partially emitted history and applies the reserved @@ -194,6 +206,10 @@ impl App { cell: &dyn HistoryCell, width: u16, ) { + if tui.is_owned_screen() { + tui.frame_requester().schedule_frame(); + return; + } if self .initial_history_replay_buffer .as_ref() @@ -274,6 +290,9 @@ impl App { } fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> { + if tui.is_owned_screen() { + return Ok(()); + } if tui.is_alt_screen_active() { tui.terminal.clear_visible_screen()?; } else { @@ -295,6 +314,11 @@ impl App { /// source-backed reflow so terminal scrollback reflects the finalized cell instead of the /// transient stream rows. pub(super) fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + tui.frame_requester().schedule_frame(); + return Ok(()); + } if self.transcript_reflow.take_stream_finish_reflow_needed() { self.schedule_immediate_resize_reflow(tui); let screen_size = tui.terminal.last_known_screen_size; @@ -306,7 +330,11 @@ impl App { } pub(super) fn schedule_immediate_resize_reflow(&mut self, tui: &mut tui::Tui) { - self.transcript_reflow.schedule_immediate(); + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + } else { + self.transcript_reflow.schedule_immediate(); + } tui.frame_requester().schedule_frame(); } @@ -316,6 +344,11 @@ impl App { /// replaced as one styled source-backed cell. If this reflow is skipped after a stream-time /// resize, the visible scrollback can keep the pre-consolidation wrapping. pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + tui.frame_requester().schedule_frame(); + return Ok(()); + } // Capped initial replay normally buffers per-cell display rows. A live stream tail is // consolidated directly into `transcript_cells`, so any retained rows no longer describe // the canonical transcript. Let the replay-end event render the capped transcript tail @@ -398,6 +431,17 @@ impl App { size: ratatui::layout::Size, ) -> Result<()> { self.flush_native_history(tui); + if tui.is_owned_screen() { + let width = self.transcript_reflow.note_width(size.width); + if width.changed || width.initialized { + self.chat_widget.on_terminal_resize(size.width); + } + if size != tui.terminal.last_known_screen_size { + self.refresh_status_line(); + } + self.discard_scrollback_render_work(tui); + return Ok(()); + } let should_rebuild_transcript = self.handle_draw_size_change( size, tui.terminal.last_known_screen_size, @@ -424,6 +468,10 @@ impl App { tui: &mut tui::Tui, screen_size: ratatui::layout::Size, ) -> Result<()> { + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + return Ok(()); + } let Some(deadline) = self.transcript_reflow.pending_until() else { return Ok(()); }; @@ -467,6 +515,11 @@ impl App { tui: &mut tui::Tui, terminal_width: TerminalWidth, ) -> Result { + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + tui.frame_requester().schedule_frame(); + return Ok(terminal_width); + } let width = self.chat_widget.history_wrap_width(terminal_width.0); if self.transcript_cells.is_empty() { // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. @@ -550,6 +603,11 @@ impl App { tui: &mut tui::Tui, terminal_width: TerminalWidth, ) -> Result<()> { + if tui.is_owned_screen() { + self.discard_scrollback_render_work(tui); + tui.frame_requester().schedule_frame(); + return Ok(()); + } self.native_history.replayed(); let width = self.chat_widget.history_wrap_width(terminal_width.0); let reflowed_lines = if self.transcript_cells.is_empty() { @@ -573,6 +631,14 @@ impl App { Ok(()) } + /// Discard terminal render products while preserving the canonical cells and view position. + fn discard_scrollback_render_work(&mut self, tui: &mut tui::Tui) { + self.transcript_reflow.clear_pending_reflow(); + self.transcript_reflow.clear_stream_flags(); + self.reset_history_emission_state(); + tui.clear_pending_history_lines(); + } + /// Render transcript cells for the current resize rebuild. /// /// Rendering walks backward from the transcript tail so row-capped sessions avoid formatting the diff --git a/codex-rs/tui/src/app/resize_reflow_tests.rs b/codex-rs/tui/src/app/resize_reflow_tests.rs index e0ff1156d3..c1c3994971 100644 --- a/codex-rs/tui/src/app/resize_reflow_tests.rs +++ b/codex-rs/tui/src/app/resize_reflow_tests.rs @@ -164,7 +164,7 @@ async fn capped_resize_reflow_prepends_transcript_notice_without_changing_transc .map(rendered_line_text) .collect::>() .join("\n"), - @" + @r" Earlier messages are available — press ctrl+t to view the full transcript cell 8 @@ -243,7 +243,7 @@ async fn paginated_resize_reflow_prepends_transcript_notice_for_unloaded_history .map(rendered_line_text) .collect::>() .join("\n"), - @" + @r" Earlier messages are available — press ctrl+t to view the full transcript cell 0 diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 1c32645b61..d1eea17889 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -139,7 +139,7 @@ impl App { /*startup_draft*/ None, ) .await?; - resume_config.1 = crate::local_settings::LocalSettings::from(&resume_config.0); + resume_config.1 = self.local_settings.reloaded(&resume_config.0); Ok(resume_config) } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 403f9b79a7..d93107cd98 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -770,7 +770,11 @@ impl App { } self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); - Self::clear_terminal_for_thread_switch(&mut tui.terminal)?; + if tui.is_owned_screen() { + tui.terminal.clear_visible_screen()?; + } else { + Self::clear_terminal_for_thread_switch(&mut tui.terminal)?; + } Ok(()) } @@ -978,7 +982,7 @@ impl App { tracing::warn!("failed to unsubscribe tracked thread {thread_id}: {err}"); } } - self.local_settings = crate::local_settings::LocalSettings::from(&config); + self.local_settings = self.local_settings.reloaded(&config); self.refresh_server_version_overview_notice(CODEX_CLI_VERSION); self.config = config; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap new file mode 100644 index 0000000000..da3be283b3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap @@ -0,0 +1,39 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +80 columns · LoadingOlder + ↑ Loading earlier messages… · esc latest + +80 columns · LoadingBeginning + ↑ Loading earlier messages… · esc latest + +80 columns · Failed + Retry history: ⌥+ App { pending_server_profiles: HashMap::new(), file_search, transcript_cells: Vec::new(), + transcript_view: Default::default(), native_history: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 41f904101a..c8b5c33e88 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5840,13 +5840,71 @@ async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { } #[tokio::test] -#[cfg_attr( - target_os = "windows", - ignore = "snapshot path rendering differs on Windows" -)] -async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { - let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; - assert_app_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); +async fn ctrl_l_clears_owned_history_and_preserves_the_draft() -> Result<()> { + let (mut app, _codex_home, thread_id) = + pagination_completion_tests::completed_history_app(&["Oldest", "Newest"]).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.scrollback_has_older_history = app_server.has_older_history(thread_id); + assert!(app.scrollback_has_older_history); + let cursor = app_server + .begin_older_history_page(thread_id) + .expect("older page"); + let page = app_server + .thread_items_page( + thread_id, + /*turn_id*/ None, + Some(cursor.clone()), + /*limit*/ 100, + ) + .await?; + app.chat_widget.set_model("gpt-test"); + app.chat_widget.insert_str("draft survives"); + app.transcript_cells = vec![Arc::new(history_cell::PlainHistoryCell::new( + (0..50).map(|_| "old transcript row".into()).collect(), + ))]; + tui.set_owned_screen(/*owned*/ true)?; + let size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12); + app.render_owned_transcript(&mut tui, size)?; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + ) + .await?; + app.render_owned_transcript(&mut tui, size)?; + app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page)) + .await?; + assert!(app_server.has_older_history(thread_id)); + app.request_owned_history(&mut tui, &mut app_server); + assert!(!app_server.is_older_history_page_pending(thread_id, &cursor)); + assert_eq!(app.transcript_cells.len(), 1); + assert!( + app.transcript_cells[0] + .as_any() + .is::() + ); + let header = lines_to_single_string(&app.transcript_cells[0].display_lines(/*width*/ 80)); + assert!(header.contains("gpt-test")); + assert!(!header.contains("old transcript row")); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "draft survives" + ); + assert!(tui.is_owned_screen()); + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + Ok(()) } #[tokio::test] @@ -5912,6 +5970,7 @@ async fn make_test_app() -> App { file_search, transcript_cells: Vec::new(), native_history: Default::default(), + transcript_view: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, @@ -6014,6 +6073,7 @@ pub(super) async fn make_test_app_with_channels() -> ( file_search, transcript_cells: Vec::new(), native_history: Default::default(), + transcript_view: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, @@ -8096,6 +8156,7 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res let selected_turn = started.turns[3].clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await?; + app.scrollback_has_older_history = app_server.has_older_history(source_thread_id); { let mut store = app .thread_event_channels @@ -8268,25 +8329,21 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res .collect::>(), vec!["turn-0"] ); - let cursor = app_server - .begin_older_history_page(source_thread_id) - .expect("retained prefix should remain pageable after reverting the visible window"); - let page = app_server - .thread_items_page( - source_thread_id, - /*turn_id*/ None, - Some(cursor.clone()), - /*limit*/ 100, - ) + tui.set_owned_screen(/*owned*/ true)?; + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) .await?; - app.handle_older_history_page( - &mut tui, - &mut app_server, - source_thread_id, - &cursor, - Ok(page), - ) - .await?; + assert_eq!( + app.transcript_view.history, + crate::pager_overlay::TranscriptHistoryState::LoadingOlder + ); + loop { + let event = app_event_rx.recv().await.expect("older history page"); + if let AppEvent::OlderThreadHistoryLoaded { .. } = event { + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + break; + } + } + tui.set_owned_screen(/*owned*/ false)?; let prompts = app .transcript_cells .iter() diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 03636e9c27..6f886b8a87 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -120,7 +120,7 @@ async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<( async fn completed_global_chords_toggle_output_and_request_external_editor() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; let config = toml::from_str( - "[global]\ntoggle_raw_output = [\"ctrl-x r\"]\nopen_external_editor = [\"ctrl-x e\"]", + "[global]\ntoggle_raw_output = [\"ctrl-x r\", \"pageup\"]\nopen_external_editor = [\"ctrl-x e\"]", )?; app.keymap = RuntimeKeymap::from_config(&config).expect("valid global chords"); app.chat_widget.apply_keymap_update(config, &app.keymap); @@ -129,6 +129,30 @@ async fn completed_global_chords_toggle_output_and_request_external_editor() -> press(&mut app, &mut tui, &mut app_server, key).await?; } assert!(app.chat_widget.raw_output_mode()); + tui.set_owned_screen(/*owned*/ true)?; + press(&mut app, &mut tui, &mut app_server, KeyCode::PageUp.into()).await?; + assert!(!app.chat_widget.raw_output_mode()); + let config = toml::from_str( + "[composer]\nsubmit = 'pageup'\n[editor]\nmove_line_start = 'pagedown'\n[global]\nopen_external_editor = 'ctrl-x e'", + )?; + app.keymap = RuntimeKeymap::from_config(&config).expect("valid composer and editor bindings"); + app.chat_widget.apply_keymap_update(config, &app.keymap); + app.chat_widget.apply_external_edit("draft".to_string()); + assert!(!app.handle_owned_transcript_event( + &mut tui, + &mut app_server, + &TuiEvent::Key(KeyCode::PageUp.into()), + )?); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::PageDown.into(), + ) + .await?; + app.chat_widget.insert_str("X"); + assert_eq!(app.chat_widget.composer_text_with_pending(), "Xdraft"); + tui.set_owned_screen(/*owned*/ false)?; for key in [ctrl('x'), KeyCode::Char('e').into()] { press(&mut app, &mut tui, &mut app_server, key).await?; @@ -639,18 +663,69 @@ async fn command_center_chords_do_not_capture_search_text() -> Result<()> { #[tokio::test] async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Result<()> { - let (mut app, mut tui, app_server) = chord_app().await?; + let (mut app, mut tui, mut app_server) = chord_app().await?; app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({ - "pager": {"scroll_up": ["ctrl-space x", "ctrl-home x", "ctrl-end x"]} + "global": {"copy": ["ctrl-x ctrl-u"]}, + "pager": { + "scroll_up": ["ctrl-space x", "ctrl-home x", "ctrl-end x"], + "close_transcript": ["ctrl-x ctrl-u"] + } }))?) .expect("valid pager chords"); - app.open_transcript_overlay(&mut tui); - for code in [KeyCode::Home, KeyCode::End, KeyCode::Char(' ')] { - let key = KeyEvent::new(code, KeyModifiers::CONTROL); - assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key)); - assert!(!app.key_chord_matcher.is_pending()); + app.chat_widget.insert_str("draft"); + for owned in [true, false] { + tui.set_owned_screen(owned)?; + app.open_transcript_overlay(&mut tui); + for code in [KeyCode::Home, KeyCode::End, KeyCode::Char(' ')] { + let key = KeyEvent::new(code, KeyModifiers::CONTROL); + assert_eq!(app.route_key_chord_event(&mut tui, key), Some(key)); + assert!(!app.key_chord_matcher.is_pending()); + } + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?; + assert!(app.overlay.is_none()); + assert!(!app.transcript_view.is_detailed()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); } - app.close_transcript_overlay(&mut tui); + app.transcript_cells = vec![std::sync::Arc::new( + crate::history_cell::PlainHistoryCell::new( + (0..50).map(|_| "transcript row".into()).collect(), + ), + )]; + tui.set_owned_screen(/*owned*/ true)?; + let home = KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL); + for (binding, keys) in [ + ("ctrl-home", vec![home]), + ("ctrl-home ctrl-u", vec![home, ctrl('u')]), + ("ctrl-x ctrl-home", vec![ctrl('x'), home]), + ] { + app.keymap = RuntimeKeymap::from_config(&toml::from_str(&format!( + "[pager]\nclose_transcript = '{binding}'\n[global]\nopen_external_editor = '{binding}'" + ))?) + .expect("valid close binding"); + app.open_transcript_overlay(&mut tui); + app.render_owned_transcript( + &mut tui, + ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12), + )?; + app.transcript_view + .scroll(&app.transcript_cells, /*rows*/ -10); + assert!(app.transcript_view.can_return_to_latest()); + for key in &keys { + press(&mut app, &mut tui, &mut app_server, *key).await?; + } + assert!(!app.transcript_view.is_detailed()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + for key in keys { + press(&mut app, &mut tui, &mut app_server, key).await?; + } + assert_eq!( + app.chat_widget.external_editor_state(), + crate::chatwidget::ExternalEditorState::Requested + ); + app.reset_external_editor_state(&mut tui); + } + tui.set_owned_screen(/*owned*/ false)?; app_server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs index 54bd23ba1f..61b32cfb09 100644 --- a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -15,13 +15,16 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_state::SqliteConfig; -#[tokio::test] -async fn older_pagination_completion_footers_follow_answers_without_overlap_duplicates() --> Result<()> { +pub(super) async fn completed_history_app( + names: &[&str], +) -> Result<(App, tempfile::TempDir, ThreadId)> { let mut app = make_test_app().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + // The inline scrollback row cap fixes the page boundary used by this regression. + app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; + app.local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never; app.local_settings.tui.terminal_resize_reflow_max_rows = Some(2); let completed_at = chrono::Local .with_ymd_and_hms( @@ -48,7 +51,7 @@ async fn older_pagination_completion_footers_follow_answers_without_overlap_dupl .take(/*n*/ 1) .map(serde_json::from_str::) .collect::, _>>()?; - for (index, name) in ["Oldest", "Middle", "Newest"].into_iter().enumerate() { + for (index, name) in names.iter().enumerate() { let turn_id = format!("turn-{index}"); let finished = completed_at.timestamp() + index as i64 * 60; let mut events = vec![EventMsg::TurnStarted(TurnStartedEvent { @@ -112,6 +115,14 @@ async fn older_pagination_completion_footers_follow_answers_without_overlap_dupl .collect::>() .join("\n"); std::fs::write(path, format!("{records}\n"))?; + Ok((app, codex_home, thread_id)) +} + +#[tokio::test] +async fn older_pagination_completion_footers_follow_answers_without_overlap_duplicates() +-> Result<()> { + let (mut app, _codex_home, thread_id) = + completed_history_app(&["Oldest", "Middle", "Newest"]).await?; let (mut app_server, _requests, proxy) = start_recording_app_server( &app.config, /*blocked_thread_list*/ None, diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index bedcf8cdfa..36b4765947 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -266,7 +266,7 @@ impl App { { return; } - let local_settings = crate::local_settings::LocalSettings::from(&config); + let local_settings = self.local_settings.reloaded(&config); let keymap = match RuntimeKeymap::from_config(&local_settings.tui.keymap) { Ok(keymap) => keymap, Err(error) => return self.chat_widget.add_error_message(error), diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index a84aab94dc..379d6be171 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -150,6 +150,14 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { + if tui.is_owned_screen() { + self.transcript_view.set_presentation( + /*detailed*/ true, + self.chat_widget.history_render_mode(), + ); + tui.frame_requester().schedule_frame(); + return; + } let _ = tui.enter_alt_screen(); self.overlay = Some(Overlay::new_transcript( self.transcript_cells.clone(), @@ -165,6 +173,16 @@ impl App { /// Close the current overlay and restore normal UI, retaining Analytics navigation state. pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { + if tui.is_owned_screen() && self.overlay.is_none() { + self.transcript_view.set_presentation( + /*detailed*/ false, + self.chat_widget.history_render_mode(), + ); + self.backtrack.overlay_preview_active = false; + self.reset_backtrack_state(); + tui.frame_requester().schedule_frame(); + return; + } let _ = tui.leave_alt_screen(); let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b219ad2892..e873af7a38 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,4 +1,5 @@ //! The chat composer is the bottom-pane text input state machine. +//! A borrowed transcript Find footer supplies its own caret without changing the draft cursor. //! //! It edits the [`TextArea`] buffer and attachment elements, routes popup keys, promotes //! completed slash commands to atomic elements, and handles Enter submission/newlines. @@ -296,7 +297,6 @@ use super::footer::SummaryLeft; use super::footer::can_show_left_with_context; use super::footer::context_window_line; use super::footer::esc_hint_mode; -use super::footer::footer_height; use super::footer::footer_hint_items_width; use super::footer::footer_line_width; use super::footer::inset_footer_hint_area; @@ -361,7 +361,9 @@ mod vim_search; use self::attachment_state::AttachmentState; use self::draft_state::ComposerMentionBinding; use self::draft_state::DraftState; +pub(crate) use self::footer_state::ComposerRenderOptions; use self::footer_state::FooterState; +pub(crate) use self::footer_state::TranscriptFooter; use self::history_search::HistorySearchSession; use self::popup_state::ActivePopup; use self::popup_state::DismissedToken; @@ -1068,18 +1070,17 @@ impl ChatComposer { self.windows_degraded_sandbox_active = enabled; } fn layout_areas(&self, area: Rect) -> [Rect; 4] { - self.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + self.layout_areas_with_options(area, ComposerRenderOptions::default()) } - fn layout_areas_with_textarea_right_reserve( + fn layout_areas_with_options( &self, area: Rect, - textarea_right_reserve: u16, + options: ComposerRenderOptions<'_>, ) -> [Rect; 4] { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(&footer_props)); + let options = self.resolve_render_options(options); + let textarea_right_reserve = options.textarea_right_reserve; + let footer_hint_height = self.footer_hint_height(options); let footer_total_height = footer_hint_height + Self::footer_spacing(footer_hint_height); let popup_height = self .popups @@ -1126,32 +1127,9 @@ impl ChatComposer { } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + self.cursor_pos_with_options(area, ComposerRenderOptions::default()) } - pub(crate) fn cursor_pos_with_textarea_right_reserve( - &self, - area: Rect, - textarea_right_reserve: u16, - ) -> Option<(u16, u16)> { - if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() { - return None; - } - - if let Some(pos) = self - .vim_search_cursor_pos(area) - .or_else(|| self.history_search_cursor_pos(area)) - { - return Some(pos); - } - - let [_, _, textarea_rect, _] = - self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); - let state = *self.draft.textarea_state.borrow(); - self.draft - .textarea - .cursor_pos_with_state(textarea_rect, state) - } /// Returns true if the composer currently contains no user-entered input. pub(crate) fn is_empty(&self) -> bool { self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty() @@ -4593,7 +4571,7 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + self.cursor_pos_with_options(area, ComposerRenderOptions::default()) } fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { @@ -4605,7 +4583,7 @@ impl Renderable for ChatComposer { } fn desired_height(&self, width: u16) -> u16 { - self.desired_height_with_textarea_right_reserve(width, /*textarea_right_reserve*/ 0) + self.desired_height_with_options(width, ComposerRenderOptions::default()) } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -4622,15 +4600,14 @@ impl ChatComposer { true } - pub(crate) fn desired_height_with_textarea_right_reserve( + pub(crate) fn desired_height_with_options( &self, width: u16, - textarea_right_reserve: u16, + options: ComposerRenderOptions<'_>, ) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(&footer_props)); + let options = self.resolve_render_options(options); + let textarea_right_reserve = options.textarea_right_reserve; + let footer_hint_height = self.footer_hint_height(options); let footer_total_height = footer_hint_height + Self::footer_spacing(footer_hint_height); const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; let inner_width = @@ -4656,20 +4633,19 @@ impl ChatComposer { impl ChatComposer { pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option) { - self.render_with_mask_and_textarea_right_reserve( - area, buf, mask_char, /*textarea_right_reserve*/ 0, - ); + self.render_with_options(area, buf, mask_char, ComposerRenderOptions::default()); } - pub(crate) fn render_with_mask_and_textarea_right_reserve( + pub(crate) fn render_with_options( &self, area: Rect, buf: &mut Buffer, mask_char: Option, - textarea_right_reserve: u16, + options: ComposerRenderOptions<'_>, ) { + let options = self.resolve_render_options(options); let [composer_rect, remote_images_rect, textarea_rect, popup_rect] = - self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); + self.layout_areas_with_options(area, options); match &self.popups.active { ActivePopup::Command(popup) => { popup.render_ref(popup_rect, buf); @@ -4703,21 +4679,11 @@ impl ChatComposer { | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; - let custom_height = self.custom_footer_height(); - let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(&footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - if let Some(input) = self.draft.textarea.vim_query() { + let hint_rect = self.footer_hint_area(popup_rect, options); + if let Some(footer) = options.footer { + Paragraph::new(footer.text.clone()) + .render(inset_footer_hint_area(hint_rect), buf); + } else if let Some(input) = self.draft.textarea.vim_query() { input.render(inset_footer_hint_area(hint_rect), buf); } else if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); @@ -5044,7 +5010,7 @@ impl ChatComposer { self.render_sparkle( composer_rect, textarea_rect, - self.cursor_pos_with_textarea_right_reserve(area, textarea_right_reserve), + self.cursor_pos_with_options(area, options), buf, ); } @@ -5088,6 +5054,7 @@ mod tests { use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::footer::footer_height; use crate::bottom_pane::textarea::TextArea; use codex_protocol::models::local_image_label_text; use tokio::sync::mpsc::UnboundedReceiver; @@ -12319,7 +12286,7 @@ mod tests { let count = 32; let mut now = Instant::now(); - let step = Duration::from_millis(1); + let step = Duration::from_millis(/*millis*/ 1); for _ in 0..count { let _ = composer.handle_input_basic_with_time( KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), @@ -12366,7 +12333,7 @@ mod tests { let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder let mut now = Instant::now(); - let step = Duration::from_millis(1); + let step = Duration::from_millis(/*millis*/ 1); for _ in 0..count { let _ = composer.handle_input_basic_with_time( KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 009c4831f6..4ffdfd3fdd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -1,18 +1,124 @@ //! Footer and status-row presentation state for the chat composer. //! Owners schedule flash expiry redraws; replacing a draft clears its flash. +//! Borrowed transcript feedback uses one resolved presentation for height, paint, and cursor. use std::time::Instant; +use ratatui::layout::Rect; use ratatui::text::Line; use crate::bottom_pane::footer::CollaborationModeIndicator; use crate::bottom_pane::footer::FooterMode; use crate::bottom_pane::footer::GoalStatusIndicator; +use crate::bottom_pane::footer::inset_footer_hint_area; use crate::key_hint::KeyBinding; use crate::key_hint::ShortcutHint; use std::time::Duration; +/// Per-frame transcript feedback rendered by the composer footer owner. +pub(crate) struct TranscriptFooter { + pub(crate) text: ratatui::text::Text<'static>, + /// Caret in the first footer row, measured from its inset content area. + pub(crate) cursor_column: Option, + pub(crate) is_interactive: bool, +} + +/// Borrowed presentation shared by composer measurement, painting, and cursor placement. +#[derive(Clone, Copy, Default)] +pub(crate) struct ComposerRenderOptions<'a> { + pub(crate) textarea_right_reserve: u16, + pub(crate) footer: Option<&'a TranscriptFooter>, +} + impl super::ChatComposer { + pub(crate) fn cursor_pos_with_options( + &self, + area: Rect, + options: ComposerRenderOptions<'_>, + ) -> Option<(u16, u16)> { + let options = self.resolve_render_options(options); + if let Some(footer) = options.footer.filter(|footer| footer.is_interactive) { + let [_, _, _, popup_rect] = self.layout_areas_with_options(area, options); + let area = inset_footer_hint_area(self.footer_hint_area(popup_rect, options)); + return footer + .cursor_column + .filter(|column| *column < area.width && area.height > 0) + .map(|column| (area.x + column, area.y)); + } + if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() { + return None; + } + + if let Some(pos) = self + .vim_search_cursor_pos(area) + .or_else(|| self.history_search_cursor_pos(area)) + { + return Some(pos); + } + + let [_, _, textarea_rect, _] = self.layout_areas_with_options(area, options); + let state = *self.draft.textarea_state.borrow(); + self.draft + .textarea + .cursor_pos_with_state(textarea_rect, state) + } + + pub(super) fn resolve_render_options<'a>( + &self, + mut options: ComposerRenderOptions<'a>, + ) -> ComposerRenderOptions<'a> { + options.footer = options.footer.filter(|footer| { + matches!(self.popups.active, super::ActivePopup::None) + && self.history_search.is_none() + && self.draft.textarea.vim_query().is_none() + && !self.quit_shortcut_hint_visible() + && (footer.is_interactive + || (!self.footer.flash_visible() + && self.footer.hint_override.is_none() + && matches!( + self.footer_mode(), + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft + ))) + }); + options + } + + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + pub(crate) fn shortcut_overlay_visible(&self) -> bool { + self.footer_mode() == FooterMode::ShortcutOverlay + && matches!(self.popups.active, super::ActivePopup::None) + && self.custom_footer_height().is_none() + } + + pub(super) fn footer_hint_height(&self, options: ComposerRenderOptions<'_>) -> u16 { + options.footer.map_or_else( + || { + self.custom_footer_height() + .unwrap_or_else(|| super::super::footer::footer_height(&self.footer_props())) + }, + |footer| footer.text.height().try_into().unwrap_or(u16::MAX), + ) + } + + pub(super) fn footer_hint_area( + &self, + popup_rect: ratatui::layout::Rect, + options: ComposerRenderOptions<'_>, + ) -> ratatui::layout::Rect { + let footer_hint_height = self.footer_hint_height(options); + let footer_spacing = Self::footer_spacing(footer_hint_height); + if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = ratatui::layout::Layout::vertical([ + ratatui::layout::Constraint::Length(footer_spacing), + ratatui::layout::Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + } + } + pub(crate) fn footer_flash_delay(&self) -> Option { self.footer .flash diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs index 318de06df6..e6ad3005e2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs @@ -55,8 +55,7 @@ fn draw(composer: &ChatComposer, width: u16, now: Instant) -> (Buffer, Rect) { width, composer.desired_height(width), ); - let [padding, _, textarea, _] = - composer.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0); + let [padding, _, textarea, _] = composer.layout_areas(area); let phase = composer.sparkle.phase.replace(Phase::Unarmed); let mut buffer = Buffer::empty(area); composer.render(area, &mut buffer); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b3bf6c00e6..7e9a26a6aa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -39,6 +39,8 @@ use crate::terminal_palette::effective_stdout_color_level; use crate::tui::FrameRequester; pub(crate) use bottom_pane_view::BottomPaneView; pub(crate) use bottom_pane_view::ViewCompletion; +pub(crate) use chat_composer::ComposerRenderOptions; +pub(crate) use chat_composer::TranscriptFooter; use codex_app_server_protocol::SkillMetadata; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_features::Features; @@ -152,7 +154,6 @@ pub(crate) use list_selection_view::SideContentWidth; pub(crate) use list_selection_view::popup_content_width; pub(crate) use list_selection_view::side_by_side_layout_widths; pub(crate) use memories_settings_view::MemoriesSettingsView; -pub(crate) use picker_style::selection_style; use slash_commands::ServiceTierCommand; mod feedback_note_view; mod feedback_view; @@ -163,6 +164,8 @@ pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_success_cell; pub(crate) use feedback_view::feedback_upload_consent_params; +pub(crate) use footer::inset_footer_hint_area; +pub(crate) use picker_style::selection_style; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; @@ -313,6 +316,11 @@ pub(crate) struct BottomPaneParams { } impl BottomPane { + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + self.composer.show_footer_flash(line, duration); + self.request_redraw(); + } + pub fn new(params: BottomPaneParams) -> Self { Self::new_with_composer_config(params, ChatComposerConfig::default()) } @@ -1658,6 +1666,11 @@ impl BottomPane { && !self.inline_banner_accepts_dismissal() } + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + pub(crate) fn shortcut_overlay_visible(&self) -> bool { + self.no_modal_or_popup_active() && self.composer.shortcut_overlay_visible() + } + /// Return true when no popups or modal views are active, regardless of task state. pub(crate) fn can_launch_external_editor(&self) -> bool { self.view_stack.is_empty() @@ -2011,6 +2024,16 @@ impl BottomPane { &'_ self, composer_right_reserve: u16, ) -> RenderableItem<'_> { + self.as_renderable_with_options(ComposerRenderOptions { + textarea_right_reserve: composer_right_reserve, + ..ComposerRenderOptions::default() + }) + } + + pub(crate) fn as_renderable_with_options<'a>( + &'a self, + options: ComposerRenderOptions<'a>, + ) -> RenderableItem<'a> { if (self.is_task_running || !self.view_stack.is_empty()) && let Some(banner) = &self.inline_banner { @@ -2104,12 +2127,12 @@ impl BottomPane { flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into())); let composer: RenderableItem<'_> = if let Some(questions) = question_editor { RenderableItem::Borrowed(questions.as_ref()) - } else if composer_right_reserve == 0 { + } else if options.textarea_right_reserve == 0 && options.footer.is_none() { RenderableItem::Borrowed(&self.composer) } else { RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable { composer: &self.composer, - right_reserve: composer_right_reserve, + options, })) }; flex2.push(/*flex*/ 0, composer); @@ -2160,27 +2183,22 @@ impl BottomPane { struct ChatComposerRightReserveRenderable<'a> { composer: &'a chat_composer::ChatComposer, - right_reserve: u16, + options: ComposerRenderOptions<'a>, } impl Renderable for ChatComposerRightReserveRenderable<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { - self.composer.render_with_mask_and_textarea_right_reserve( - area, - buf, - /*mask_char*/ None, - self.right_reserve, - ); + self.composer + .render_with_options(area, buf, /*mask_char*/ None, self.options); } fn desired_height(&self, width: u16) -> u16 { self.composer - .desired_height_with_textarea_right_reserve(width, self.right_reserve) + .desired_height_with_options(width, self.options) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.composer - .cursor_pos_with_textarea_right_reserve(area, self.right_reserve) + self.composer.cursor_pos_with_options(area, self.options) } fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { @@ -2204,12 +2222,6 @@ impl Renderable for BottomPane { } } -/// Transcript status and selection feedback, shared with the composer in fullscreen mode. -pub(crate) struct TranscriptFooter { - pub(crate) text: ratatui::text::Text<'static>, - pub(crate) cursor_column: Option, -} - #[cfg(test)] mod tests { #[path = "actionable_banner_tests.rs"] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1d2257ff1c..f42ed73741 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -317,6 +317,7 @@ use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod activity_groups; +mod activity_presentation; mod command_lifecycle; mod connector_mentions; mod connectors; @@ -2037,30 +2038,9 @@ impl ChatWidget { &self, width: u16, ) -> Option> { - let mut lines = Vec::new(); - if let Some(cell) = self.transcript.active_cell.as_ref() { - lines.extend(cell.transcript_hyperlink_lines(width)); - } - for cell in self - .realtime_conversation - .pending_history_cells - .iter() - .chain(self.realtime_conversation.live_transcript_cells()) - { - let realtime_lines = cell.transcript_hyperlink_lines(width); - if !realtime_lines.is_empty() && !lines.is_empty() { - lines.push(HyperlinkLine::from("")); - } - lines.extend(realtime_lines); - } - if let Some(rate_limit_reset_hint) = self.pending_rate_limit_reset_hint() { - let hint_lines = rate_limit_reset_hint.transcript_hyperlink_lines(width); - if !hint_lines.is_empty() && !lines.is_empty() { - lines.push(HyperlinkLine::from("")); - } - lines.extend(hint_lines); - } - (!lines.is_empty()).then_some(lines) + self.active_cell_hyperlink_lines_with(width, |cell, width| { + cell.transcript_hyperlink_lines(width) + }) } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget/activity_presentation.rs b/codex-rs/tui/src/chatwidget/activity_presentation.rs new file mode 100644 index 0000000000..9aba668643 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/activity_presentation.rs @@ -0,0 +1,34 @@ +//! Render live conversation cells in compact or detailed form. +use super::*; +impl ChatWidget { + pub(crate) fn active_cell_hyperlink_lines_with( + &self, + width: u16, + render: impl Fn(&dyn HistoryCell, u16) -> Vec, + ) -> Option> { + let mut lines = Vec::new(); + if let Some(cell) = self.transcript.active_cell.as_ref() { + lines.extend(render(cell.as_ref(), width)); + } + for cell in self + .realtime_conversation + .pending_history_cells + .iter() + .chain(self.realtime_conversation.live_transcript_cells()) + { + let realtime_lines = render(cell.as_ref(), width); + if !realtime_lines.is_empty() && !lines.is_empty() { + lines.push(HyperlinkLine::from("")); + } + lines.extend(realtime_lines); + } + if let Some(rate_limit_reset_hint) = self.pending_rate_limit_reset_hint() { + let hint_lines = render(rate_limit_reset_hint, width); + if !hint_lines.is_empty() && !lines.is_empty() { + lines.push(HyperlinkLine::from("")); + } + lines.extend(hint_lines); + } + (!lines.is_empty()).then_some(lines) + } +} diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index fbfd16b9e4..6ec82096b3 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -5,6 +5,10 @@ use crate::bottom_pane::BottomPaneView; use crate::clipboard_copy::CopyFormat; impl ChatWidget { + pub(crate) fn shortcut_overlay_visible(&self) -> bool { + self.bottom_pane.shortcut_overlay_visible() + } + pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { self.bottom_pane.set_agents_navigation_enabled(enabled); } @@ -260,6 +264,11 @@ impl ChatWidget { } pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + if items.is_some() { + // Active input instructions supersede transient feedback from a previous action. + self.bottom_pane + .show_footer_flash(Line::default(), Duration::ZERO); + } self.bottom_pane.set_footer_hint_override(items); } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 2b4e7af633..9ef8d32107 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -183,15 +183,7 @@ impl ChatWidget { })), ); } - let bottom = if self.external_writer_view && !self.bottom_pane.has_active_view() { - RenderableItem::Owned(Box::new(ExternalWriterNotice { - command_center_available: self.remote_connection.is_some(), - transcript_hint: self.bottom_pane.transcript_shortcut_hint(), - })) - } else { - self.bottom_pane - .as_renderable_with_composer_right_reserve(active_cell_right_reserve) - }; + let bottom = self.bottom_pane_renderable(/*footer*/ None); flex.push( /*flex*/ 0, bottom.inset(Insets::tlbr( @@ -201,6 +193,25 @@ impl ChatWidget { RenderableItem::Owned(Box::new(flex)) } + /// Render only the composer and its local surfaces beneath an owned transcript. + pub(crate) fn bottom_pane_renderable<'a>( + &'a self, + footer: Option<&'a crate::bottom_pane::TranscriptFooter>, + ) -> RenderableItem<'a> { + if self.external_writer_view && !self.bottom_pane.has_active_view() { + RenderableItem::Owned(Box::new(ExternalWriterNotice { + command_center_available: self.remote_connection.is_some(), + transcript_hint: self.bottom_pane.transcript_shortcut_hint(), + })) + } else { + self.bottom_pane + .as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions { + textarea_right_reserve: self.ambient_pet_wrap_reserved_cols(), + footer, + }) + } + } + pub(crate) fn note_rendered_width(&self, width: u16) { self.last_rendered_width.set(Some(width)); } diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs index 02a5d6bed9..c4164e808b 100644 --- a/codex-rs/tui/src/daemon_startup.rs +++ b/codex-rs/tui/src/daemon_startup.rs @@ -81,7 +81,7 @@ fn allowed_feature(name: &str) -> bool { matches!( name, // Client gates and per-thread settings already forwarded in thread requests. - "daemon_auto_start" | "worktrees" | "realtime_conversation" | "standalone_web_search" + "daemon_auto_start" | "worktrees" | "transcript_v2" | "realtime_conversation" | "standalone_web_search" // Shared services and threadless MCP operations need daemon compatibility checks. | "api_key_model_discovery" | "code_mode_host" | "auth_elicitation" | "mcp_oauth_refresh_coordination" diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 2a6fa087ac..28a0bf9fb7 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -8,6 +8,10 @@ use tempfile::TempDir; #[test] fn audited_overrides_allow_daemon_without_allowing_arbitrary_config() { for (raw, eligible) in [ + ("features.transcript_v2=true", true), + ("features.transcript_v2=false", true), + ("features={transcript_v2=true}", true), + ("features.transcript_v2='true'", false), ("features.worktrees=true", true), ("features.worktrees=false", true), ("features={worktrees=true}", true), diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 862f14c7d8..f62e7d1cc2 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -41,11 +41,24 @@ pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) struct KeymapContextSet(u16); +const TRANSCRIPT_CLOSE: u16 = 1 << 15; + impl KeymapContextSet { pub(crate) const fn new(context: KeymapContext) -> Self { Self(context_bit(context)) } + pub(crate) const fn with_transcript_close(self) -> Self { + Self(self.0 | TRANSCRIPT_CLOSE) + } + + pub(crate) fn contains_action(self, action: KeymapActionId) -> bool { + self.contains(action.context) + || (self.0 & TRANSCRIPT_CLOSE != 0 + && action.context == KeymapContext::Pager + && action.action == "close_transcript") + } + pub(crate) const fn with(self, context: KeymapContext) -> Self { Self(self.0 | context_bit(context)) } @@ -281,11 +294,20 @@ impl KeyChordMatcher { if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) { return KeyChordMatch::Cancelled; } - if let Some(binding) = keymap.bindings.iter().find(|binding| { - contexts.contains(binding.action.context) - && binding.chord.prefix == pending.prefix - && chord_stroke_matches(binding.chord.completion, key_event) - }) { + if let Some(binding) = keymap + .bindings + .iter() + .filter(|binding| { + contexts.contains_action(binding.action) + && binding.chord.prefix == pending.prefix + && chord_stroke_matches(binding.chord.completion, key_event) + }) + .min_by_key(|binding| { + !(contexts.0 & TRANSCRIPT_CLOSE != 0 + && binding.action.context == KeymapContext::Pager + && binding.action.action == "close_transcript") + }) + { let Some(dispatch_event) = dispatch_event(binding.action) else { return KeyChordMatch::Ignored; }; @@ -297,7 +319,7 @@ impl KeyChordMatcher { .bindings .iter() .find(|binding| { - contexts.contains(binding.action.context) + contexts.contains_action(binding.action) && chord_stroke_matches(binding.chord.prefix, key_event) }) .map(|binding| binding.chord.prefix) diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index b23229074d..e853f25234 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -4,18 +4,22 @@ //! responses must never refresh these values; live preference changes belong here. The remaining //! Config-based lifecycle adapters also use this conversion until their interfaces are migrated. //! Effective animations also respect the TUI host's launch-time accessibility preference. +//! The selected transcript ownership and alternate-screen restrictions survive local reloads. use crate::legacy_core::config::Config; use crate::legacy_core::config::TerminalResizeReflowConfig; use crate::legacy_core::config::TerminalResizeReflowMaxRows; +use crate::transcript_mode::TranscriptMode; use codex_config::types::History; use codex_config::types::Notice; use codex_config::types::Tui; +use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Clone, Debug, PartialEq)] pub(crate) struct LocalSettings { pub(crate) tui: Tui, + pub(crate) transcript_mode: TranscriptMode, pub(crate) history: History, pub(crate) notices: Notice, pub(crate) codex_home: AbsolutePathBuf, @@ -51,6 +55,10 @@ impl LocalSettings { config.animations }; Self { + transcript_mode: TranscriptMode::resolve( + config.features.enabled(Feature::TranscriptV2), + config.tui_alternate_screen != codex_config::types::AltScreenMode::Never, + ), tui: Tui { notification_settings: config.tui_notifications.clone(), animations: animations && system_motion == crate::motion::MotionMode::Animated, @@ -93,6 +101,26 @@ impl LocalSettings { } impl LocalSettings { + /// Adopt the screen selected before first paint, including command-line restrictions. + pub(crate) fn for_tui(config: &Config, tui: &crate::tui::Tui) -> Self { + let mut settings = Self::from(config); + settings.transcript_mode = tui.transcript_mode(); + if !tui.is_alt_screen_enabled() { + settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never; + } else if settings.tui.alternate_screen == codex_config::types::AltScreenMode::Never { + settings.tui.alternate_screen = codex_config::types::AltScreenMode::Auto; + } + settings + } + + /// Refresh editable preferences without changing this launch's terminal ownership. + pub(crate) fn reloaded(&self, config: &Config) -> Self { + let mut settings = Self::from(config); + settings.transcript_mode = self.transcript_mode; + settings.tui.alternate_screen = self.tui.alternate_screen; + settings + } + pub(crate) fn terminal_resize_reflow(&self) -> TerminalResizeReflowConfig { TerminalResizeReflowConfig { max_rows: match self.tui.terminal_resize_reflow_max_rows { diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index d30d67f673..6cc6eb67ee 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -5,6 +5,48 @@ use codex_config::LoaderOverrides; use codex_config::types::SessionPickerViewMode; use pretty_assertions::assert_eq; +#[tokio::test] +async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<()> { + use crate::transcript_mode::TranscriptMode; + use codex_config::types::AltScreenMode; + + let home = tempfile::tempdir()?; + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + config.features.enable(Feature::TranscriptV2)?; + config.tui_alternate_screen = AltScreenMode::Auto; + + for (alternate_screen, owned, expected_mode, expected_alt) in [ + (true, true, TranscriptMode::Owned, AltScreenMode::Auto), + (true, false, TranscriptMode::Terminal, AltScreenMode::Auto), + (false, true, TranscriptMode::Terminal, AltScreenMode::Never), + ] { + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_alt_screen_enabled(alternate_screen); + tui.set_owned_screen(owned)?; + let local = LocalSettings::for_tui(&config, &tui); + assert_eq!( + (local.transcript_mode, local.tui.alternate_screen), + (expected_mode, expected_alt), + ); + + let mut reloaded_config = config.clone(); + reloaded_config.features.disable(Feature::TranscriptV2)?; + reloaded_config.tui_alternate_screen = AltScreenMode::Never; + reloaded_config.tui_theme = Some("nord".into()); + let mut expected = LocalSettings::from(&reloaded_config); + expected.transcript_mode = expected_mode; + expected.tui.alternate_screen = expected_alt; + assert_eq!(local.reloaded(&reloaded_config), expected); + assert_eq!(LocalSettings::for_tui(&reloaded_config, &tui), expected); + tui.set_owned_screen(/*owned*/ false)?; + } + Ok(()) +} + #[tokio::test] async fn system_motion_suppresses_animations_without_changing_saved_preferences() -> anyhow::Result<()> { diff --git a/codex-rs/tui/src/session_start.rs b/codex-rs/tui/src/session_start.rs index 045c0e770d..ba41e706e5 100644 --- a/codex-rs/tui/src/session_start.rs +++ b/codex-rs/tui/src/session_start.rs @@ -1,4 +1,5 @@ //! Interactive recovery when starting an archived session. +//! Retries preserve the launch-resolved local presentation settings from the first attempt. use crate::app::AppExitInfo; use crate::app::ExitReason; @@ -7,6 +8,7 @@ use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::ForkPermissionMode; use crate::app_server_session::ResumeModelSettings; use crate::legacy_core::config::Config; +use crate::local_settings::LocalSettings; use crate::resume_picker::SessionTarget; use crate::unarchive_prompt::UnarchiveChoice; use color_eyre::Result; @@ -30,19 +32,19 @@ impl SessionStartAction { self, app_server: &mut AppServerSession, config: &Config, + local_settings: &LocalSettings, target: &SessionTarget, ) -> Result { - let local_settings = crate::local_settings::LocalSettings::from(config); match self { Self::Resume(settings) => { app_server - .resume_thread(&local_settings, config.clone(), target.thread_id, settings) + .resume_thread(local_settings, config.clone(), target.thread_id, settings) .await } Self::Fork(permission_mode) => { app_server .fork_thread_with_permission_mode( - &local_settings, + local_settings, config.clone(), target.thread_id, permission_mode, @@ -60,9 +62,16 @@ pub(crate) enum SessionStartOutcome { Exit, } +/// The first attempt's session configuration and launch-resolved presentation settings. +/// Archived-session retries must reuse both so thread configuration cannot change ownership. +pub(crate) struct SessionStartConfig<'a> { + pub(crate) config: &'a Config, + pub(crate) local_settings: &'a LocalSettings, +} + pub(crate) async fn complete_session_start( app_server: &mut AppServerSession, - config: &Config, + config: SessionStartConfig<'_>, app_server_target: &crate::AppServerTarget, target: &SessionTarget, action: SessionStartAction, @@ -101,7 +110,7 @@ pub(crate) async fn complete_session_start( .wrap_err_with(|| format!("Failed to unarchive session {}", target.thread_id))?; // Retry by ID, not by the old rollout path, which unarchiving may have moved. action - .start(app_server, config, target) + .start(app_server, config.config, config.local_settings, target) .await .map(|started| SessionStartOutcome::Started(Box::new(started))) .map_err(|err| session_start_error(action.verb(), target, err)) diff --git a/codex-rs/tui/src/session_start_tests.rs b/codex-rs/tui/src/session_start_tests.rs index 0872a254a7..8c020272bb 100644 --- a/codex-rs/tui/src/session_start_tests.rs +++ b/codex-rs/tui/src/session_start_tests.rs @@ -1,3 +1,5 @@ +//! Archived-session confirmation retries retain the first request settings. + use super::*; use crate::legacy_core::config::ConfigBuilder; use app_test_support::create_fake_rollout; @@ -15,10 +17,16 @@ async fn start_session( action: SessionStartAction, confirm: impl AsyncFnOnce() -> Result, ) -> Result { - let initial_result = action.start(app_server, config, target).await; + let local_settings = LocalSettings::from(config); + let initial_result = action + .start(app_server, config, &local_settings, target) + .await; complete_session_start( app_server, - config, + SessionStartConfig { + config, + local_settings: &local_settings, + }, &crate::AppServerTarget::Embedded, target, action, @@ -63,6 +71,7 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul }; let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; let prompts = Cell::new(0); + let local_settings = LocalSettings::from(&config); let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; for server_target in [ @@ -74,10 +83,15 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul crate::AppServerTarget::Embedded, ] { for choice in [UnarchiveChoice::Cancel, UnarchiveChoice::Quit] { - let initial = action.start(&mut app_server, &config, &target).await; + let initial = action + .start(&mut app_server, &config, &local_settings, &target) + .await; let outcome = complete_session_start( &mut app_server, - &config, + SessionStartConfig { + config: &config, + local_settings: &local_settings, + }, &server_target, &target, action, diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 171878a029..e88f02808b 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -776,7 +776,8 @@ pub(super) async fn run_main_inner( tracing::warn!("Could not save screen-reader detection: {err}"); } - let app_result = run_ratatui_app( + // Keep the large app future off the enclosing CLI startup stack during transitions. + let app_result = Box::pin(run_ratatui_app( cli, arg0_paths, loader_overrides, @@ -796,7 +797,7 @@ pub(super) async fn run_main_inner( daemon_startup_warning, launch_telemetry, startup_draft, - ) + )) .await .map_err(|err| { err.downcast::() diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index b891c0babf..6ea10e9b66 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -5,6 +5,8 @@ //! them and rewrapping does not turn a reading position into an unrelated screen row. mod activity; +mod composer_gap; +mod follow_control; mod footer; mod input; mod layout; @@ -73,6 +75,8 @@ struct VisibleRow { /// Shared scrolling and interaction state for compact and detailed transcript presentations. pub(crate) struct TranscriptView { + follow_control: follow_control::FollowControl, + copy_feedback: Option, position: Position, cache: LayoutCache, live: Option>, @@ -98,6 +102,8 @@ impl Default for TranscriptView { fn default() -> Self { Self { position: Position::Latest, + follow_control: follow_control::FollowControl::default(), + copy_feedback: None, cache: LayoutCache::default(), live: None, live_separated: None, @@ -121,6 +127,10 @@ impl Default for TranscriptView { } impl TranscriptView { + pub(crate) fn is_detailed(&self) -> bool { + self.detailed + } + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer, cells: &[Arc]) { self.cache.begin_frame(); self.sync_history_tail(cells); diff --git a/codex-rs/tui/src/transcript_view/composer_gap.rs b/codex-rs/tui/src/transcript_view/composer_gap.rs new file mode 100644 index 0000000000..8d71636873 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/composer_gap.rs @@ -0,0 +1,108 @@ +//! Share the existing composer gap between transient copy feedback and reading controls. +//! Feedback wins while visible; controls release their pointer targets when replaced. + +use super::*; +use crate::clipboard_copy::CopyStatus; +use crate::footer_hint::first_fitting_line; +use std::time::Duration; +use std::time::Instant; + +pub(super) struct CopyFeedback { + result: Result, + characters: usize, + expires_at: Instant, +} + +impl TranscriptView { + pub(crate) fn show_copy_feedback( + &mut self, + result: &Result, + characters: usize, + ) { + self.copy_feedback = Some(CopyFeedback { + result: result.as_ref().copied().map_err(|_| ()), + characters, + expires_at: Instant::now() + Duration::from_secs(/*secs*/ 5), + }); + } + + pub(crate) fn render_composer_gap( + &mut self, + area: Option, + hint: Option<&Line<'static>>, + buffer: &mut Buffer, + ) -> Option { + if self + .copy_feedback + .as_ref() + .is_some_and(|feedback| feedback.expires_at <= Instant::now()) + { + self.copy_feedback = None; + } + let Some(area) = area.filter(|area| !area.is_empty()) else { + self.render_follow_control(/*area*/ None, buffer); + return None; + }; + if let Some(feedback) = &self.copy_feedback { + let line = feedback.line(area.width.saturating_sub(/*rhs*/ 1)); + let delay = feedback + .expires_at + .saturating_duration_since(Instant::now()); + let width = line.width().min(usize::from(area.width)) as u16; + let target = Rect::new( + area.right().saturating_sub(width + 1).max(area.x), + area.y, + width, + /*height*/ 1, + ); + line.render(target, buffer); + self.render_follow_control(/*area*/ None, buffer); + return Some(delay); + } + self.render_follow_control(Some(area), buffer); + if self.is_following() + && !self.has_active_interaction() + && let Some(hint) = hint.filter(|hint| hint.width() + 2 <= usize::from(area.width)) + { + let width = hint.width() as u16; + hint.render( + Rect::new(area.right() - width - 1, area.y, width, /*height*/ 1), + buffer, + ); + } + None + } +} + +impl CopyFeedback { + fn line(&self, width: u16) -> Line<'static> { + let characters = self.characters; + let labels = match self.result { + Ok(CopyStatus::Confirmed) => [ + format!("Copied {characters} chars to host clipboard"), + format!("Copied {characters} chars"), + "Copied".into(), + ], + Ok(CopyStatus::Unconfirmed) => [ + "Copy sent to terminal · paste to verify".into(), + "Copy sent · verify paste".into(), + "Copy unconfirmed".into(), + ], + Err(()) => [ + "Copy failed · /export saves chat".into(), + "Copy failed · /export".into(), + "Copy failed".into(), + ], + }; + let line = first_fitting_line(labels.map(Line::from), width); + if self.result.is_err() { + line.red() + } else { + line.fg(crate::style::accent_color()) + } + } +} + +#[cfg(test)] +#[path = "composer_gap_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/composer_gap_tests.rs b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs new file mode 100644 index 0000000000..63007c9964 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/composer_gap_tests.rs @@ -0,0 +1,57 @@ +//! Copy outcomes remain truthful at narrow widths and release stale navigation hit targets. + +use super::*; +use crate::transcript_view::tests::cell; +use crate::transcript_view::tests::render; +use crate::transcript_view::tests::text; +use pretty_assertions::assert_eq; + +#[test] +fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() { + let mut snapshots = Vec::new(); + for result in [ + Ok(CopyStatus::Confirmed), + Ok(CopyStatus::Unconfirmed), + Err("unavailable".to_owned()), + ] { + for width in [80, 32, 18] { + let mut view = TranscriptView::default(); + view.show_copy_feedback(&result, /*characters*/ 24); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1); + let mut buffer = Buffer::empty(area); + assert!( + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer) + .is_some() + ); + assert_eq!(buffer[(width - 1, 0)].symbol(), " "); + snapshots.push(format!("{result:?}, {width} columns\n{}", text(&buffer))); + } + } + insta::assert_snapshot!(snapshots.join("\n\n")); +} + +#[test] +fn feedback_releases_navigation_targets_and_expiry_restores_them() { + let cells = vec![cell("one\ntwo\nthree\nfour\nfive")]; + let mut view = TranscriptView::default(); + render(&mut view, &cells, /*width*/ 60, /*height*/ 3); + view.scroll(&cells, /*rows*/ -2); + let area = Rect::new( + /*x*/ 0, /*y*/ 3, /*width*/ 60, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer); + assert!(text(&buffer).contains("Back to bottom")); + view.show_copy_feedback(&Ok(CopyStatus::Unconfirmed), /*characters*/ 3); + buffer.reset(); + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer); + assert!(!text(&buffer).contains("Back to bottom")); + assert!(!view.is_following()); + view.copy_feedback.as_mut().unwrap().expires_at = Instant::now(); + buffer.reset(); + assert_eq!( + view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer), + None + ); + assert!(text(&buffer).contains("Back to bottom")); +} diff --git a/codex-rs/tui/src/transcript_view/follow_control.rs b/codex-rs/tui/src/transcript_view/follow_control.rs new file mode 100644 index 0000000000..f7128d6e9f --- /dev/null +++ b/codex-rs/tui/src/transcript_view/follow_control.rs @@ -0,0 +1,105 @@ +//! Return-to-latest affordance in the owned transcript's existing composer gap. +//! Rendering owns the hit rectangle; hidden controls never intercept pointer input. + +use super::*; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use ratatui::layout::Position as ScreenPosition; +use ratatui::widgets::Paragraph; +use unicode_width::UnicodeWidthStr; + +#[derive(Default)] +pub(super) struct FollowControl { + area: Option, + pointer: Option, + pressed: bool, +} + +impl TranscriptView { + pub(crate) fn render_follow_control(&mut self, area: Option, buf: &mut Buffer) { + let area = area.filter(|area| { + !area.is_empty() + && self.can_return_to_latest() + && self.highlight.is_none() + && self.selection.is_none() + }); + let Some(area) = area else { + self.follow_control = FollowControl::default(); + return; + }; + let labels = if self.unseen_activity { + [ + " New activity · ↓ Back to bottom · esc ", + " New activity · ↓ Bottom ", + " New · ↓ Bottom ", + " ↓ Bottom ", + " ↓ ", + ] + } else { + [ + " ↓ Back to bottom · esc ", + " ↓ Back to bottom ", + " ↓ Bottom · esc ", + " ↓ Bottom ", + " ↓ ", + ] + }; + let Some(label) = labels + .into_iter() + .find(|label| label.width() <= usize::from(area.width)) + else { + self.follow_control = FollowControl::default(); + return; + }; + let width = label.width() as u16; + let target = Rect::new( + area.x + (area.width - width) / 2, + area.y, + width, + /*height*/ 1, + ); + let hovered = self + .follow_control + .pointer + .is_some_and(|point| target.contains(point)); + self.follow_control.area = Some(target); + let style = + crate::style::user_message_style().fg(crate::style::user_message_accent_color()); + let style = if hovered { + style.reversed().bold() + } else { + style + }; + Paragraph::new(label).style(style).render(target, buf); + } + + pub(super) fn handle_follow_control_mouse(&mut self, event: MouseEvent) -> Option { + let control = &mut self.follow_control; + let area = control.area?; + let point = ScreenPosition::new(event.column, event.row); + let was_hovered = control.pointer.is_some_and(|point| area.contains(point)); + let inside = area.contains(point); + control.pointer = Some(point); + match event.kind { + MouseEventKind::Down(MouseButton::Left) if inside => { + control.pressed = true; + Some(ViewAction::Changed) + } + MouseEventKind::Up(MouseButton::Left) if control.pressed => { + control.pressed = false; + if inside { + self.jump_to_latest(); + } + Some(ViewAction::Changed) + } + MouseEventKind::Drag(MouseButton::Left) if control.pressed => Some(ViewAction::Changed), + MouseEventKind::Moved if inside != was_hovered => Some(ViewAction::Changed), + _ => None, + } + } +} + +#[cfg(test)] +#[path = "follow_control_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/follow_control_tests.rs b/codex-rs/tui/src/transcript_view/follow_control_tests.rs new file mode 100644 index 0000000000..93dcb4b153 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/follow_control_tests.rs @@ -0,0 +1,260 @@ +//! Follow gestures and the visible return control share one reading-state contract. + +use super::*; +use crate::history_cell::PlainHistoryCell; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +fn cell(text: &str) -> Arc { + Arc::new(PlainHistoryCell::new( + text.lines().map(|line| line.to_owned().into()).collect(), + )) +} + +fn paint(view: &mut TranscriptView, cells: &[Arc], width: u16) -> Buffer { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 4); + let mut buf = Buffer::empty(area); + view.render( + Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 3), + &mut buf, + cells, + ); + view.render_follow_control( + Some(Rect::new( + /*x*/ 0, /*y*/ 3, width, /*height*/ 1, + )), + &mut buf, + ); + buf +} + +fn mouse( + view: &mut TranscriptView, + cells: &[Arc], + kind: MouseEventKind, + column: u16, + row: u16, +) { + view.handle_mouse( + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }, + cells, + ); +} + +#[test] +fn empty_click_restores_following_but_not_an_existing_pause() { + for following in [true, false] { + let mut cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + paint(&mut view, &cells, /*width*/ 40); + if !following { + view.scroll(&cells, /*rows*/ -1); + paint(&mut view, &cells, /*width*/ 40); + } + mouse( + &mut view, + &cells, + MouseEventKind::Down(MouseButton::Left), + /*column*/ 0, + /*row*/ 0, + ); + cells.push(cell("arrived during click")); + paint(&mut view, &cells, /*width*/ 40); + mouse( + &mut view, + &cells, + MouseEventKind::Up(MouseButton::Left), + /*column*/ 0, + /*row*/ 0, + ); + assert_eq!( + (view.is_following(), view.selected_text(&cells)), + (following, None) + ); + } +} + +#[test] +fn no_op_scroll_keeps_following_and_real_scroll_pauses() { + let cells = vec![cell("one\ntwo")]; + let mut view = TranscriptView::default(); + paint(&mut view, &cells, /*width*/ 40); + view.scroll(&cells, /*rows*/ -3); + assert!(view.is_following()); + // Scrolling toward an unloaded page is meaningful even when the loaded rows fit. + view.history = TranscriptHistoryState::Partial; + view.scroll(&cells, /*rows*/ -3); + assert!(!view.is_following()); + assert!(view.needs_history(&cells)); + view.history = TranscriptHistoryState::Complete; + view.jump_to_latest(); + let cells = vec![cell("one\ntwo\nthree\nfour")]; + paint(&mut view, &cells, /*width*/ 40); + view.scroll(&cells, /*rows*/ -3); + assert!(view.can_return_to_latest()); + view.scroll(&cells, /*rows*/ 3); + assert!(view.is_following()); +} + +#[test] +fn copying_live_selection_retains_the_displayed_revision() { + let mut view = TranscriptView::default(); + let cells = Vec::new(); + view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("original live text")]) + }); + paint(&mut view, &cells, /*width*/ 40); + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + let selected = view.selected_text(&cells).unwrap(); + view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| { + Some(vec![HyperlinkLine::from("changed live text")]) + }); + view.copy_selected_text_with(&cells, &selected, |_| { + Ok(crate::clipboard_copy::CopyStatus::Confirmed) + }) + .unwrap(); + let buffer = paint(&mut view, &cells, /*width*/ 40); + assert!(!view.is_following()); + assert!(format!("{buffer:?}").contains("original live text")); + view.jump_to_latest(); + assert!(format!("{:?}", paint(&mut view, &cells, /*width*/ 40)).contains("changed live text")); +} + +#[test] +fn control_tracks_pause_activity_hover_resize_and_click() { + let mut cells = vec![cell("one\ntwo\nthree\nfour\nfive")]; + let mut view = TranscriptView::default(); + paint(&mut view, &cells, /*width*/ 80); + assert!(view.follow_control.area.is_none()); + view.scroll(&cells, /*rows*/ -2); + let mut snapshots = vec![format!( + "paused\n{:?}", + paint(&mut view, &cells, /*width*/ 80) + )]; + cells.push(cell("new output")); + for width in [80, 20] { + snapshots.push(format!( + "activity at {width}\n{:?}", + paint(&mut view, &cells, width) + )); + if width == 80 { + let area = view.follow_control.area.unwrap(); + mouse(&mut view, &cells, MouseEventKind::Moved, area.x, area.y); + snapshots.push(format!("hover\n{:?}", paint(&mut view, &cells, width))); + } + } + let area = view.follow_control.area.unwrap(); + mouse( + &mut view, + &cells, + MouseEventKind::Down(MouseButton::Left), + area.x, + area.y, + ); + mouse( + &mut view, + &cells, + MouseEventKind::Up(MouseButton::Left), + area.x, + area.y, + ); + assert!(view.is_following()); + paint(&mut view, &cells, /*width*/ 20); + assert!(view.follow_control.area.is_none()); + insta::assert_snapshot!("follow_control", snapshots.join("\n\n")); +} + +#[test] +fn hidden_control_does_not_consume_clicks_and_drag_out_cancels() { + let cells = vec![cell("one\ntwo\nthree\nfour")]; + let mut view = TranscriptView::default(); + paint(&mut view, &cells, /*width*/ 40); + view.scroll(&cells, /*rows*/ -1); + paint(&mut view, &cells, /*width*/ 40); + let area = view.follow_control.area.unwrap(); + mouse( + &mut view, + &cells, + MouseEventKind::Down(MouseButton::Left), + area.x, + area.y, + ); + mouse( + &mut view, + &cells, + MouseEventKind::Up(MouseButton::Left), + /*column*/ 0, + /*row*/ 0, + ); + assert!(!view.is_following()); + let mut buf = Buffer::empty(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 4, + )); + view.render_follow_control(/*area*/ None, &mut buf); + assert!( + view.handle_follow_control_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }) + .is_none() + ); +} + +#[test] +fn height_changes_backfill_after_empty_click_but_preserve_deliberate_pause() { + let rows = (0..60) + .map(|row| format!("row {row:02}")) + .collect::>() + .join("\n"); + let cells = vec![cell(&rows)]; + for pause in ["none", "scroll", "selection"] { + let mut view = TranscriptView::default(); + let short = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 24, + ); + view.render(short, &mut Buffer::empty(short), &cells); + if pause == "scroll" { + view.scroll(&cells, /*rows*/ -6); + view.render(short, &mut Buffer::empty(short), &cells); + } + if pause == "selection" { + view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3); + } else { + mouse( + &mut view, + &cells, + MouseEventKind::Down(MouseButton::Left), + /*column*/ 0, + /*row*/ 0, + ); + mouse( + &mut view, + &cells, + MouseEventKind::Up(MouseButton::Left), + /*column*/ 0, + /*row*/ 0, + ); + } + let before = (view.visible[0].key, view.visible[0].row); + let selected = view.selected_text(&cells); + for height in [50, 24, 50] { + let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 120, height); + view.render(area, &mut Buffer::empty(area), &cells); + assert_eq!(view.is_following(), pause == "none"); + assert_eq!(view.selected_text(&cells), selected); + if pause == "none" { + assert_eq!(view.visible.len(), usize::from(height)); + assert_eq!(view.visible.last().unwrap().row, 59); + } else { + assert_eq!((view.visible[0].key, view.visible[0].row), before); + } + } + } +} diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs index 9528b68c7d..ca846fe21c 100644 --- a/codex-rs/tui/src/transcript_view/footer.rs +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -41,6 +41,7 @@ impl TranscriptView { ) .into(), cursor_column: None, + is_interactive: true, }); } @@ -102,6 +103,7 @@ impl TranscriptView { ) .into(), cursor_column: None, + is_interactive: false, } }) } diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 97c5ba79bd..2ad117bb2b 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -138,6 +138,9 @@ impl TranscriptView { event: MouseEvent, cells: &[Arc], ) -> Option { + if let Some(action) = self.handle_follow_control_mouse(event) { + return Some(action); + } let inside = self .area .contains(ScreenPosition::new(event.column, event.row)); diff --git a/codex-rs/tui/src/transcript_view/mutations.rs b/codex-rs/tui/src/transcript_view/mutations.rs index 248d292541..36f7aa1fb4 100644 --- a/codex-rs/tui/src/transcript_view/mutations.rs +++ b/codex-rs/tui/src/transcript_view/mutations.rs @@ -55,7 +55,13 @@ impl TranscriptView { range: Range, replacement: &Arc, ) { - if range.is_empty() || self.snapshot().is_some() { + if range.is_empty() { + return; + } + if range.end == cells.len() && self.last_tail == cells.last().map(EntryKey::cell) { + self.last_tail = Some(EntryKey::cell(replacement)); + } + if self.snapshot().is_some() { return; } let reading = match self.position { diff --git a/codex-rs/tui/src/transcript_view/selection.rs b/codex-rs/tui/src/transcript_view/selection.rs index 1d37b152e8..0c9ca400f3 100644 --- a/codex-rs/tui/src/transcript_view/selection.rs +++ b/codex-rs/tui/src/transcript_view/selection.rs @@ -214,6 +214,7 @@ impl TranscriptView { /// End the pointer gesture without discarding selected text when input ownership changes. pub(crate) fn end_drag(&mut self) { + self.follow_control = Default::default(); if !self.has_selection_range() && self .selection diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap new file mode 100644 index 0000000000..cc189d77a8 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__composer_gap__tests__copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/transcript_view/composer_gap_tests.rs +assertion_line: 30 +expression: "snapshots.join(\"\\n\\n\")" +--- +Ok(Confirmed), 80 columns + Copied 24 chars to host clipboard + +Ok(Confirmed), 32 columns + Copied 24 chars + +Ok(Confirmed), 18 columns + Copied 24 chars + +Ok(Unconfirmed), 80 columns + Copy sent to terminal · paste to verify + +Ok(Unconfirmed), 32 columns + Copy sent · verify paste + +Ok(Unconfirmed), 18 columns + Copy unconfirmed + +Err("unavailable"), 80 columns + Copy failed · /export saves chat + +Err("unavailable"), 32 columns + Copy failed · /export + +Err("unavailable"), 18 columns + Copy failed diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap new file mode 100644 index 0000000000..7a83407326 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__follow_control__tests__follow_control.snap @@ -0,0 +1,62 @@ +--- +source: tui/src/transcript_view/follow_control_tests.rs +assertion_line: 173 +expression: "snapshots.join(\"\\n\\n\")" +--- +paused +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 4 }, + content: [ + "one ", + "two ", + "three ", + " ↓ Back to bottom · esc ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} + +activity at 80 +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 4 }, + content: [ + "one ", + "two ", + "three ", + " New activity · ↓ Back to bottom · esc ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} + +hover +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 4 }, + content: [ + "one ", + "two ", + "three ", + " New activity · ↓ Back to bottom · esc ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 20, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED, + x: 59, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} + +activity at 20 +Buffer { + area: Rect { x: 0, y: 0, width: 20, height: 4 }, + content: [ + "one ", + "two ", + "three ", + " New · ↓ Bottom ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index a6ec40f1d7..fe295e67b1 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -457,6 +457,10 @@ fn resize_keeps_the_logical_reading_anchor() { "final answer\n" ); assert!(!view.is_following()); + assert!(!view.unseen_activity); + cells.push(cell("new output")); + render(&mut view, &cells, /*width*/ 20, /*height*/ 2); + assert!(view.unseen_activity); } #[test] diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index f4ae0cb0a2..efcdf83056 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -662,6 +662,10 @@ where } impl Tui { + pub(crate) fn is_alt_screen_enabled(&self) -> bool { + self.alt_screen_enabled + } + pub(crate) fn new( terminal: Terminal, enhanced_keys_supported: bool, @@ -716,6 +720,17 @@ impl Tui { self.alt_screen_enabled = enabled; } + /// Defer a fresh fullscreen launch until its first synchronized frame is ready. + pub(crate) fn prepare_owned_screen(&mut self, owned: bool) -> Result<()> { + if owned && self.alt_screen_enabled && !self.is_alt_screen_active() { + self.owned_screen = true; + self.frame_requester.schedule_frame(); + Ok(()) + } else { + self.set_owned_screen(owned) + } + } + /// Own the terminal for the session, unless alternate-screen rendering is disabled. /// /// Closing an overlay retains the screen; external programs and suspension temporarily @@ -1165,6 +1180,11 @@ impl Tui { )?; } + if self.owned_screen && !self.is_alt_screen_active() { + self.enter_alt_screen()?; + pending_viewport_area = None; + } + let terminal = &mut self.terminal; if let Some(new_area) = pending_viewport_area.take() { terminal.set_viewport_area(new_area); @@ -1307,6 +1327,10 @@ impl Tui { )?; } + if self.owned_screen && !self.is_alt_screen_active() { + self.enter_alt_screen()?; + } + let terminal = &mut self.terminal; let needs_full_repaint = Self::update_inline_viewport_for_resize_reflow( terminal, diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index 4baa91b7dc..816b8191a6 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -28,7 +28,7 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R let codex_home = tempfile::tempdir()?; write_test_config(codex_home.path(), &repo_root)?; - let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?; + let mut terminal = PtyCodex::start(&repo_root, codex_home, &["--no-alt-screen"])?; terminal.wait_for_startup()?; let startup_output_len = terminal.output.len(); @@ -121,7 +121,12 @@ async fn interactive_startup_honors_codex_home_symlink_opt_out() -> Result<()> { let mut terminal = PtyCodex::start( &workspace_path, codex_home, - &["-c", "model_provider=\"test\"", "Write ready.txt"], + &[ + "--no-alt-screen", + "-c", + "model_provider=\"test\"", + "Write ready.txt", + ], )?; terminal.wait_for_startup()?; let deadline = Instant::now() + STARTUP_TIMEOUT; @@ -148,6 +153,105 @@ async fn interactive_startup_honors_codex_home_symlink_opt_out() -> Result<()> { ); } +#[test] +fn owned_screen_entry_paints_before_sync_ends_and_exit_clears_inline_draft() -> Result<()> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex_home = tempfile::tempdir()?; + write_test_config(codex_home.path(), &repo_root)?; + let mut terminal = PtyCodex::start( + &repo_root, + codex_home, + &[ + "-c", + "tui.alternate_screen=\"always\"", + "-c", + "features.transcript_v2=true", + ], + )?; + terminal.wait_for_startup()?; + let deadline = Instant::now() + STARTUP_TIMEOUT; + while !terminal.parser.screen().alternate_screen() && Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 50))?; + terminal.answer_startup_queries()?; + terminal.ensure_running()?; + } + ensure!( + terminal.parser.screen().alternate_screen(), + "owned screen did not open" + ); + terminal.wait_for_screen("GPT-5.6-Terra")?; + let enter_alt = b"\x1b[?1049h"; + let begin_sync = b"\x1b[?2026h"; + let end_sync = b"\x1b[?2026l"; + let entry = terminal + .output + .windows(enter_alt.len()) + .position(|bytes| bytes == enter_alt) + .context("missing owned-screen entry")?; + let begin = terminal.output[..entry] + .windows(begin_sync.len()) + .rposition(|bytes| bytes == begin_sync) + .context("missing synchronized update before owned-screen entry")?; + ensure!( + !terminal.output[begin..entry] + .windows(end_sync.len()) + .any(|bytes| bytes == end_sync), + "owned-screen entry happened outside a synchronized update" + ); + let end = entry + + terminal.output[entry..] + .windows(end_sync.len()) + .position(|bytes| bytes == end_sync) + .context("missing synchronized update end after owned-screen entry")?; + let (rows, cols) = terminal.parser.screen().size(); + let mut first_frame = vt100::Parser::new(rows, cols, /*scrollback_len*/ 0); + first_frame.process(&terminal.output[..end]); + let first_contents = first_frame.screen().contents(); + ensure!( + first_contents.contains("OpenAI Codex") + && first_contents.contains("Ask Codex to do anything"), + "owned-screen synchronization ended before its first complete loading frame:\n{first_contents}" + ); + let composer_row = first_contents + .lines() + .position(|line| line.contains("Ask Codex to do anything")) + .context("missing composer in first owned-screen frame")?; + assert_eq!( + ( + first_contents.matches("Ask Codex to do anything").count(), + first_frame.screen().cursor_position(), + first_frame.screen().hide_cursor(), + ), + (1, (u16::try_from(composer_row)?, 2), false), + "first owned-screen frame must contain one composer with its visible cursor" + ); + terminal.write_input(b"\x1b[99;5u")?; + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + terminal.read_output(Duration::from_millis(/*millis*/ 50))?; + if let Some(status) = terminal.child.try_wait()? { + ensure!(status.success(), "owned screen exited with {status}"); + break; + } + ensure!( + Instant::now() < deadline, + "owned screen did not exit; screen:\n{}", + terminal.screen_contents() + ); + } + ensure!( + !terminal.parser.screen().alternate_screen(), + "alternate screen was not restored" + ); + ensure!( + !terminal + .screen_contents() + .contains("Ask Codex to do anything"), + "owned-screen exit left the inline composer visible" + ); + Ok(()) +} + pub(super) struct PtyCodex { master: File, child: Child, @@ -164,6 +268,28 @@ impl PtyCodex { repo_root: &Path, codex_home: TempDir, extra_args: &[&str], + ) -> Result { + let codex = codex_utils_cargo_bin::cargo_bin("codex-tui") + .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?; + Self::start_binary(&codex, repo_root, codex_home, extra_args) + } + + /// Include the CLI dispatch futures when testing production stack headroom. + pub(super) fn start_cli( + repo_root: &Path, + codex_home: TempDir, + extra_args: &[&str], + ) -> Result { + let codex = codex_utils_cargo_bin::cargo_bin("codex") + .context("build codex-cli and set CARGO_BIN_EXE_codex to its executable")?; + Self::start_binary(&codex, repo_root, codex_home, extra_args) + } + + fn start_binary( + codex: &Path, + repo_root: &Path, + codex_home: TempDir, + extra_args: &[&str], ) -> Result { let mut master_fd = -1; let mut slave_fd = -1; @@ -196,14 +322,17 @@ impl PtyCodex { let stdin = slave.try_clone().context("clone pseudo-terminal stdin")?; let stdout = slave.try_clone().context("clone pseudo-terminal stdout")?; - let codex = codex_utils_cargo_bin::cargo_bin("codex-tui") - .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?; let child = Command::new(codex) .args(extra_args) - .arg("--no-alt-screen") .arg("-C") .arg(repo_root) .env("TERM", "xterm-256color") + // This PTY answers its own capability probes; it is not inside the caller's mux. + .env_remove("TMUX") + .env_remove("TMUX_PANE") + .env_remove("STY") + .env("TERM_PROGRAM", "kitty") + .env_remove("TERM_PROGRAM_VERSION") .env("OPENAI_API_KEY", "focus-palette-test") .env("CODEX_HOME", codex_home.path()) .stdin(stdin) @@ -285,7 +414,8 @@ impl PtyCodex { } if !self.keyboard_answered && contains_bytes(&self.output, b"\x1b[?u") { - self.write_input(b"\x1b[?0u\x1b[?1;2c")?; + self.write_input(b"\x1b[?7u")?; + self.write_input(b"\x1b[?1;2c")?; self.keyboard_answered = true; } diff --git a/codex-rs/tui/tests/suite/reconnect.rs b/codex-rs/tui/tests/suite/reconnect.rs index aee7ad9b5a..f9a279a5f0 100644 --- a/codex-rs/tui/tests/suite/reconnect.rs +++ b/codex-rs/tui/tests/suite/reconnect.rs @@ -144,7 +144,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re } Ok::<_, anyhow::Error>(methods) }); - let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?; + let mut terminal = PtyCodex::start(&repo_root, codex_home, &["--no-alt-screen"])?; terminal.wait_for_startup()?; let mut disconnect_tx = Some(disconnect_tx); let mut restore_tx = Some(restore_tx); diff --git a/codex-rs/tui/tests/suite/resize_reflow.rs b/codex-rs/tui/tests/suite/resize_reflow.rs index 89928de3af..0bcc8775ce 100644 --- a/codex-rs/tui/tests/suite/resize_reflow.rs +++ b/codex-rs/tui/tests/suite/resize_reflow.rs @@ -12,6 +12,7 @@ use core_test_support::responses; use core_test_support::skip_if_no_network; use tempfile::tempdir; use wiremock::MockServer; +use wiremock::matchers::body_string_contains; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] @@ -29,9 +30,8 @@ async fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() - let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; - let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; - let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); - write_config(codex_home.path(), &repo_root)?; + let _response_mock = mount_resize_response(&server).await; + write_config(codex_home.path(), &repo_root, &server.uri())?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-reflow-smoke-{}", std::process::id()); @@ -60,8 +60,6 @@ async fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() - .arg(codex) .arg("-c") .arg("analytics.enabled=false") - .arg("-c") - .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) @@ -196,9 +194,8 @@ async fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<() let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; - let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; - let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); - write_config(codex_home.path(), &repo_root)?; + let _response_mock = mount_resize_response(&server).await; + write_config(codex_home.path(), &repo_root, &server.uri())?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-width-{}", std::process::id()); @@ -227,8 +224,6 @@ async fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<() .arg(codex) .arg("-c") .arg("analytics.enabled=false") - .arg("-c") - .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) @@ -327,8 +322,7 @@ async fn tmux_scrolled_composer_resize_preserves_visible_draft_text() -> Result< let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; - let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); - write_config(codex_home.path(), &repo_root)?; + write_config(codex_home.path(), &repo_root, &server.uri())?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-scrolled-composer-{}", std::process::id()); @@ -358,8 +352,6 @@ async fn tmux_scrolled_composer_resize_preserves_visible_draft_text() -> Result< .arg("gpt-5.6-terra") .arg("-c") .arg("analytics.enabled=false") - .arg("-c") - .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root), @@ -453,9 +445,8 @@ async fn run_repeated_resize_smoke() -> Result<()> { let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; - let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; - let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); - write_config(codex_home.path(), &repo_root)?; + let _response_mock = mount_resize_response(&server).await; + write_config(codex_home.path(), &repo_root, &server.uri())?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-repeat-{}", std::process::id()); @@ -484,8 +475,6 @@ async fn run_repeated_resize_smoke() -> Result<()> { .arg(codex) .arg("-c") .arg("analytics.enabled=false") - .arg("-c") - .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) @@ -598,13 +587,24 @@ fn codex_binary(repo_root: &Path) -> Result { Ok(fallback) } -fn write_config(codex_home: &Path, repo_root: &Path) -> Result<()> { +fn write_config(codex_home: &Path, repo_root: &Path, server_url: &str) -> Result<()> { let repo_root_display = repo_root.display(); let config = format!( r#"model = "gpt-5.4" -model_provider = "openai" +model_provider = "resize_mock" suppress_unstable_features_warning = true +# The resize fixture serves HTTP SSE only, not WebSocket upgrades. +[model_providers.resize_mock] +name = "Resize mock" +base_url = "{server_url}/v1" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false + +[notice.model_migrations] +"gpt-5.4" = "gpt-5.6-terra" + [projects."{repo_root_display}"] trust_level = "trusted" "# @@ -621,6 +621,28 @@ fn write_auth(codex_home: &Path) -> Result<()> { Ok(()) } +// Title generation and the visible turn race; neither may consume the other's response. +async fn mount_resize_response(server: &MockServer) -> responses::ResponseMock { + let _title = responses::mount_sse_once_match( + server, + body_string_contains(r#"\"thread_source\":\"system\""#), + responses::sse(vec![ + responses::ev_assistant_message( + "resize-title", + r#"{"title":"Test terminal resizing"}"#, + ), + responses::ev_completed("resize-title"), + ]), + ) + .await; + responses::mount_sse_once_match( + server, + body_string_contains(r#"\"thread_source\":\"user\""#), + resize_reflow_sse(), + ) + .await +} + fn resize_reflow_sse() -> String { let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height."; responses::sse(vec![ diff --git a/codex-rs/tui/tests/suite/worktree_stack.rs b/codex-rs/tui/tests/suite/worktree_stack.rs index a86ec7f8b0..ee28ab173d 100644 --- a/codex-rs/tui/tests/suite/worktree_stack.rs +++ b/codex-rs/tui/tests/suite/worktree_stack.rs @@ -1,4 +1,6 @@ //! Exercises stack-sensitive session transitions through the real TUI event loop. +//! +//! Uses the CLI binary when available (including Bazel CI) to cover its dispatch frames. use super::focus_palette::PtyCodex; use super::focus_palette::write_test_config; @@ -17,6 +19,11 @@ use wiremock::matchers::body_string_contains; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Result<()> { + let start = if codex_utils_cargo_bin::cargo_bin("codex").is_ok() { + PtyCodex::start_cli + } else { + PtyCodex::start + }; let repository = tempfile::tempdir_in("/tmp")?; let root = repository.path().canonicalize()?; let status = Command::new("git") @@ -63,9 +70,19 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul server.uri() ), )?; + // Title generation can include the user prompt and race the actual user turn. + let _title = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"system\""#), + responses::sse(vec![ + responses::ev_assistant_message("title", r#"{"title":"Stack transition test"}"#), + responses::ev_completed("title-response"), + ]), + ) + .await; let _reply = responses::mount_sse_once_match( &server, - body_string_contains("marker requested by this test"), + body_string_contains(r#"\"thread_source\":\"user\""#), responses::sse(vec![ responses::ev_assistant_message("saved", "STACK_SAVED_HISTORY"), responses::ev_completed("saved-response"), @@ -77,11 +94,12 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul codex_home.path(), /*desktop*/ None, )?); - // The launcher gives codex-main and Tokio workers explicit 16 MiB stacks. - let mut terminal = PtyCodex::start( + // Both launchers give codex-main and Tokio workers explicit 16 MiB stacks. + let mut terminal = start( &root, codex_home, &[ + "--no-alt-screen", "-c", "features.worktrees=true", "-c", @@ -93,11 +111,23 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul terminal.wait_for_screen("STACK_SAVED_HISTORY")?; terminal.wait_for_screen("Ask Codex to do anything")?; - submit(&mut terminal, "/side")?; - terminal.wait_for_screen("Side from main thread")?; - terminal.ensure_running()?; - terminal.write_input(b"\x03")?; - terminal.wait_for_screen("STACK_SAVED_HISTORY")?; + // Starting and closing a side conversation both rebuild the displayed chat widget. + // Repeat through the real event loop, whose dev-build frames share the production stack. + for attempt in 1..=3 { + submit(&mut terminal, "/side")?; + terminal.wait_for_screen("Side from main thread")?; + terminal.ensure_running()?; + terminal.write_input(b"\x03")?; + terminal.wait_for_screen("STACK_SAVED_HISTORY")?; + terminal.wait_for_screen("Ask Codex to do anything")?; + terminal.ensure_running()?; + + let draft = format!("SIDE_STACK_DRAFT_{attempt}"); + terminal.write_input(draft.as_bytes())?; + terminal.wait_for_screen(&draft)?; + terminal.write_input(b"\x15")?; + terminal.wait_for_screen("Ask Codex to do anything")?; + } submit(&mut terminal, "/resume")?; terminal.wait_for_screen("Resume a previous session")?; @@ -112,7 +142,10 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul let fork_bucket = forked[0].root.parent().expect("bucket"); terminal.wait_for_screen(&format!( "/{}/", - fork_bucket.file_name().unwrap().to_string_lossy() + fork_bucket + .file_name() + .expect("forked worktree bucket name") + .to_string_lossy() ))?; terminal.ensure_running()?; @@ -140,7 +173,10 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul let second_bucket = second.root.parent().expect("bucket"); terminal.wait_for_screen(&format!( "/{}/", - second_bucket.file_name().unwrap().to_string_lossy() + second_bucket + .file_name() + .expect("new worktree bucket name") + .to_string_lossy() ))?; terminal.ensure_running()?; Ok(())