diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index c42b513dd4..db67d3b765 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -62,7 +62,13 @@ async fn older_server_notice_is_visible_in_agents_overview() { let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); - insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n")); + insta::assert_snapshot!( + rendered + .lines() + .take(/*n*/ 2) + .collect::>() + .join("\n") + ); } #[tokio::test] @@ -73,14 +79,14 @@ async fn server_version_overview_notice_updates_and_clears() { let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); - insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n"), @" Service v0.151.0 < Codex CLI v0.153.0 + insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::>().join("\n"), @" Service v0.151.0 < Codex CLI v0.153.0 0 need input 0 working 0 ready"); app.update_server_version_overview_notice("0.153.0", /*server_version*/ None); let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); - insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n"), @" Agent command center + insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::>().join("\n"), @" Agent command center 0 need input 0 working 0 ready"); } diff --git a/codex-rs/tui/src/app/composer_hints_tests.rs b/codex-rs/tui/src/app/composer_hints_tests.rs index b397fc9fed..b8a06843a5 100644 --- a/codex-rs/tui/src/app/composer_hints_tests.rs +++ b/codex-rs/tui/src/app/composer_hints_tests.rs @@ -97,12 +97,14 @@ async fn escape_closes_shortcut_help_before_transcript_backtracking() -> Result< if search { app.transcript_view.begin_search(); } else { - app.handle_tui_event( + tui.screen_size_for_event(&TuiEvent::Resize(Size::new( + /*width*/ 80, /*height*/ 40, + )))?; + app.handle_owned_transcript_event( &mut tui, &mut server, - TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), - ) - .await?; + &TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)), + )?; } assert!(app.transcript_view.has_active_interaction()); app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(escape)) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 75b2d32dfe..5677d461fd 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -299,14 +299,11 @@ 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) { + if 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() - { - overlay.set_history_state(TranscriptHistoryState::Failed); + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.set_history_state(TranscriptHistoryState::Failed); + } tui.frame_requester().schedule_frame(); } tracing::warn!(%thread_id, error = %err, "failed to load older transcript history"); @@ -721,14 +718,15 @@ impl App { if self.chat_widget.thread_id() == Some(thread_id) { 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.native_history.retain(&self.transcript_cells); } + self.transcript_view = Default::default(); + self.scrollback_has_older_history = app_server.has_older_history(thread_id); self.deferred_history_lines.clear(); self.last_rendered_history_tail = None; self.last_thread_usage_status_cell = None; self.pending_thread_usage_history_refresh = false; - self.backtrack_render_pending = true; + self.backtrack_render_pending = !tui.is_owned_screen(); self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget.emit_prompt_edit_thread_event(); tui.frame_requester().schedule_frame(); @@ -749,6 +747,10 @@ impl App { self.insert_history_cell(tui, cell); } } + AppEvent::FollowTranscript => { + self.transcript_view.jump_to_latest(); + tui.frame_requester().schedule_frame(); + } AppEvent::InsertHistoryCell(cell) => { self.insert_history_cell(tui, cell); } @@ -2480,8 +2482,9 @@ impl App { )); } } - AppEvent::OpenAgentsOverview => { - self.open_agents_overview(app_server); + AppEvent::OpenAgentsOverview => self.open_agents_overview(app_server), + AppEvent::NewAgentsOverviewSession { cwd } => { + return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await; } AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => { self.apply_agents_overview_thread_refresh(app_server, request_id, result); @@ -2500,9 +2503,6 @@ impl App { AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), } } - AppEvent::NewAgentsOverviewSession { cwd } => { - return Box::pin(self.new_agents_overview_session(tui, app_server, cwd)).await; - } AppEvent::NewAgentsOverviewWorktree { cwd } => { Box::pin(self.new_agents_overview_worktree(tui, app_server, cwd)).await; } diff --git a/codex-rs/tui/src/app/experimental_features_tests.rs b/codex-rs/tui/src/app/experimental_features_tests.rs index 7d0f15446c..ed67102309 100644 --- a/codex-rs/tui/src/app/experimental_features_tests.rs +++ b/codex-rs/tui/src/app/experimental_features_tests.rs @@ -87,7 +87,7 @@ async fn experimental_features_use_selected_server_profile_and_preserve_task_set app.transcript_cells .last() .unwrap() - .display_lines(/*width*/ 120) + .transcript_lines(/*width*/ 120) .iter() .map(ToString::to_string) .collect::>() diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 70f2c75c14..6393da3e7d 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -1,7 +1,8 @@ //! Terminal history, desktop handoff, and clear-screen UI helpers for the TUI app. //! //! This module owns rendering the fresh session header, clearing inline or alternate-screen UI -//! state, and resetting transcript-related app state after `/clear` or Ctrl-L. +//! state, and resetting transcript-related app state after `/clear` or Ctrl-L. Owned-screen sessions +//! keep committed cells as the render source and never enqueue terminal-scrollback rows here. use super::*; use crate::terminal_hyperlinks::HyperlinkLine; @@ -117,7 +118,9 @@ impl App { return Ok(()); } - if self.overlay.is_some() || self.initial_history_replay_buffer.is_some() { + if !tui.is_owned_screen() + && (self.overlay.is_some() || self.initial_history_replay_buffer.is_some()) + { self.pending_thread_usage_history_refresh = true; return Ok(()); } @@ -125,11 +128,6 @@ 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(()); @@ -160,6 +158,17 @@ impl App { self.pending_thread_usage_history_refresh = false; return Ok(()); } + if tui.is_owned_screen() { + // Composite cells are backed by mutable usage state, so the retained view remeasures + // them on its next frame without replacing native terminal history. + if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() { + status_history.lines = updated_lines; + } + self.last_rendered_history_tail = None; + self.pending_thread_usage_history_refresh = false; + tui.frame_requester().schedule_frame(); + return Ok(()); + } let Some(rendered_tail) = self.last_rendered_history_tail.as_ref() else { self.insert_history_cell_lines(tui, status_cell.as_ref(), width); self.last_rendered_history_tail = Some(RenderedHistoryTail { @@ -280,6 +289,7 @@ impl App { self.clear_ui_header_cell(version).display_lines(width) } + /// Share the source-backed header between retained drawing and legacy terminal insertion. fn clear_ui_header_cell( &self, version: &'static str, @@ -334,7 +344,7 @@ impl App { // Drop queued history insertions so stale transcript lines cannot be flushed after /clear. tui.clear_pending_history_lines(); - if is_alt_screen_active { + if tui.is_owned_screen() || is_alt_screen_active { tui.terminal.clear_visible_screen()?; } else { // Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and @@ -365,7 +375,8 @@ impl App { self.overlay = None; self.transcript_cells.clear(); self.native_history = Default::default(); - self.transcript_view = crate::transcript_view::TranscriptView::default(); + self.cancel_pending_key_chord(); + self.transcript_view = Default::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/history_ui_tests.rs b/codex-rs/tui/src/app/history_ui_tests.rs index 750c665355..e07f8cb3c0 100644 --- a/codex-rs/tui/src/app/history_ui_tests.rs +++ b/codex-rs/tui/src/app/history_ui_tests.rs @@ -1,6 +1,11 @@ +//! Desktop handoff output and clear-screen history reset behavior. + use super::*; +use crate::app::tests::make_test_app_with_channels; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use pretty_assertions::assert_eq; #[test] fn desktop_thread_opened_history_snapshot() { @@ -27,3 +32,29 @@ fn render_cell(cell: &impl HistoryCell) -> String { .collect::>() .join("\n") } + +#[tokio::test] +async fn owned_clear_resets_navigation_and_retains_one_fresh_header() -> Result<()> { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.insert_history_cell( + &mut tui, + Box::new(PlainHistoryCell::new(vec!["old".into()])), + ); + app.transcript_view + .jump_to_entry(&app.transcript_cells, /*index*/ 0); + app.reset_transcript_state_after_clear(); + app.queue_clear_ui_header(&mut tui); + app.queue_clear_ui_header(&mut tui); + + assert_eq!(app.transcript_cells.len(), 1); + assert_eq!( + app.transcript_cells[0].display_lines(/*width*/ 80), + app.clear_ui_header_lines(/*width*/ 80), + ); + assert!(app.transcript_view.is_following()); + assert!(tui.pending_history_lines_for_test().is_empty()); + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index bf1f9ea84d..5ba1a0ebae 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -68,7 +68,14 @@ impl App { /*height*/ 1, )); let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation); - let bottom = chat_widget.bottom_pane_renderable(prompt_footer.as_ref().or(footer.as_ref())); + let bottom = chat_widget.bottom_pane_renderable( + prompt_footer.as_ref().or(footer.as_ref()), + if view.has_active_interaction() { + crate::bottom_pane::CommandPopupPlacement::Hidden + } else { + crate::bottom_pane::CommandPopupPlacement::Overlay + }, + ); let dashboard_visible = chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .is_some(); @@ -131,8 +138,14 @@ impl App { { *progress = crate::bottom_pane::footer_hint_items_line(&items); } - let bottom = - chat_widget.bottom_pane_renderable(prompt_footer.as_ref().or(footer.as_ref())); + let bottom = chat_widget.bottom_pane_renderable( + prompt_footer.as_ref().or(footer.as_ref()), + if view.has_active_interaction() { + crate::bottom_pane::CommandPopupPlacement::Hidden + } else { + crate::bottom_pane::CommandPopupPlacement::Overlay + }, + ); footer_height_changed = !dashboard_visible && bottom .desired_height(screen_size.width) @@ -207,9 +220,12 @@ impl App { let size = tui.prepare_draw_size()?; self.render_owned_transcript(tui, size)?; } - if !self.transcript_view.has_active_interaction() + // Visible shortcut help owns Escape before returning a paused viewport to latest. + // A transcript search or selection still owns Escape while it replaces the help footer. + if let TuiEvent::Key(key) = event + && !self.transcript_view.has_active_interaction() && self.chat_widget.shortcut_overlay_visible() - && matches!(event, TuiEvent::Key(key) if key.code == KeyCode::Esc) + && crate::key_hint::plain(KeyCode::Esc).is_press(*key) { return Ok(false); } diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs index a7000997be..9b837b7ca2 100644 --- a/codex-rs/tui/src/app/owned_transcript_input_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -142,6 +142,48 @@ async fn empty_enter_returns_to_latest_with_contextual_hints() -> Result<()> { Ok(()) } +#[tokio::test] +async fn submitting_a_draft_from_history_queues_once_and_follows_immediately() -> Result<()> { + let (mut app, mut events, _operations) = make_test_app_with_channels().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)?; + let size = Size::new(/*width*/ 80, /*height*/ 12); + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new( + (0..30).map(|row| format!("row {row}").into()).collect(), + ))]; + app.render_owned_transcript(&mut tui, size)?; + app.transcript_view + .scroll(&app.transcript_cells, /*rows*/ -10); + app.chat_widget + .apply_external_edit("send this once".to_string()); + while events.try_recv().is_ok() {} + + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyEvent::from(KeyCode::Enter)), + ) + .await?; + let mut follows = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, AppEvent::FollowTranscript) { + follows += 1; + app.handle_event(&mut tui, &mut server, event).await?; + } + } + assert_eq!(follows, 1); + assert!(app.transcript_view.is_following()); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["send this once"] + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + #[tokio::test] async fn enter_preserves_search_backtrack_and_modal_ownership_while_scrolled() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index a8cab4ac4f..f3ae83c09c 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -317,7 +317,10 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> { app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?; let expected_cursor = app .chat_widget - .bottom_pane_renderable(/*footer*/ None) + .bottom_pane_renderable( + /*footer*/ None, + crate::bottom_pane::CommandPopupPlacement::Overlay, + ) .cursor_pos(bottom_area) .expect("composer cursor"); assert_eq!( @@ -921,6 +924,98 @@ async fn offline_backtrack_keeps_the_preview_and_draft_without_reverting() -> Re Ok(()) } +#[tokio::test] +async fn slash_picker_overlays_history_without_moving_the_transcript_or_composer() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + attach_thread(&mut app, ThreadId::new()); + app.chat_widget + .set_status_line(Some("gpt-test default · /tmp/project".into())); + app.local_settings.tui.animations = false; + app.chat_widget + .set_footer_hint_override(Some(vec![("model".to_string(), "high · fast".to_string())])); + app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new( + (1..=40) + .map(|row| format!("Transcript row {row:02}: content behind the menu").into()) + .collect(), + ))]; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + for (width, height) in [(80, 14), (32, 14), (80, 7), (80, 5)] { + let size = Size::new(width, height); + tui.terminal.resize(size)?; + app.chat_widget.apply_external_edit("/m".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + 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(); + + // Changing the token reopens the menu dismissed above. + app.chat_widget.apply_external_edit("/mo".to_string()); + app.chat_widget.apply_external_edit("/m".to_string()); + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + assert_eq!(tui.terminal.last_known_cursor_pos, cursor); + let open = + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone(); + let open_text = buffer_text(&open); + if bottom.y > 0 { + let menu_y = open_text + .lines() + .position(|line| line.contains("› /model")) + .expect("available rows show command matches"); + let prefix_len = menu_y * usize::from(width); + assert_eq!( + &open.content()[..prefix_len], + &before.content()[..prefix_len] + ); + assert_ne!(open, before); + } + let composer_start = open.index_of(bottom.x, bottom.y); + assert_eq!( + &open.content()[composer_start..], + &before.content()[composer_start..] + ); + if (width, height) == (80, 14) { + insta::assert_snapshot!("slash_picker_overlays_history", open_text); + } + + app.chat_widget.apply_external_edit("/mo".to_string()); + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + let filtered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + assert_eq!( + filtered + .lines() + .take(usize::from(bottom.y)) + .any(|line| line.contains("› /model")), + bottom.y > 0, + "a single command remains visible when the menu has room", + ); + app.chat_widget.apply_external_edit("/m".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + assert_eq!(tui.terminal.last_known_cursor_pos, cursor); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom); + assert_eq!( + ( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + tui.terminal.last_known_cursor_pos + ), + (&before, cursor), + ); + } + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + #[tokio::test] async fn double_escape_browses_prompts_without_reverting_and_explains_editing() -> Result<()> { let (mut app, mut events, _operations) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index d93107cd98..c017c4c697 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -771,7 +771,7 @@ impl App { self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); if tui.is_owned_screen() { - tui.terminal.clear_visible_screen()?; + tui.terminal.clear()?; } else { Self::clear_terminal_for_thread_switch(&mut tui.terminal)?; } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap index ca70414964..7158ba7c26 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__composer_hints__tests__composer_tip_layout.snap @@ -1,9 +1,9 @@ --- source: tui/src/app/composer_hints_tests.rs +assertion_line: 37 expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapshots.join(\"\\n\\n\"))" --- 80 columns - › question Tip: f12 searches this conversation @@ -11,9 +11,9 @@ expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapsho › Ask Codex to do anything GPT-5.6-Sol default · /tmp/project + ? for shortcuts 32 columns - › question @@ -21,3 +21,4 @@ expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(snapsho › Ask Codex to do anything GPT-5.6-Sol default · /tmp/pr… + ? for shortcuts diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap index b4f64c7536..3233b6a851 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__escape_returns_to_latest.snap @@ -1,10 +1,9 @@ --- source: tui/src/app/owned_transcript_input_tests.rs -assertion_line: 257 +assertion_line: 299 expression: "format!(\"before\\n{before}\\n\\nafter\\n{}\", screen(&tui))" --- before -row 13 row 14 row 15 row 16 @@ -15,10 +14,10 @@ row 19 › draft preserved + GPT-5.6-Sol default · /tmp/project New activity · esc latest after -row 25 row 26 row 27 row 28 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap index 015be58a41..1d301eae27 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_transcript_composer_gap.snap @@ -4,7 +4,6 @@ assertion_line: 220 expression: "snapshots.join(\"\\n\\n\")" --- Latest -Transcript row 34 Transcript row 35 Transcript row 36 Transcript row 37 @@ -17,39 +16,40 @@ Transcript row 40 gpt-test default · /tmp/project + Reading -Transcript row 28 -Transcript row 29 Transcript row 30 Transcript row 31 Transcript row 32 Transcript row 33 Transcript row 34 +Transcript row 35 ↓ Back to bottom · esc › draft + gpt-test default · /tmp/project esc latest Resized multiline composer -Transcript row 28 -Transcript row 29 Transcript row 30 Transcript row 31 Transcript row 32 +Transcript row 33 ↓ Back to bottom · esc › draft second line third line + gpt-test default · /tmp/project esc latest Tiny terminal - ↓ Back to bottom · esc › draft + gpt-test default · /tmp/project esc latest Detailed @@ -59,9 +59,9 @@ Detailed - ↓ Back to bottom · esc › preserved draft + gpt-test default · /tmp/project esc latest diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap index 65f25e8fc8..b3bccf205c 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap @@ -1,6 +1,6 @@ --- source: tui/src/app/owned_transcript_tests.rs -assertion_line: 1077 +assertion_line: 1074 expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)))" --- @@ -15,7 +15,7 @@ expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_s › third question - › Ask Codex to do anything + gpt-test default · /tmp/project Browsing · ↑↓/jk scroll · ←→/hl prompts · ctrl+t details · ↵ rewind · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap new file mode 100644 index 0000000000..f25cc394f3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__slash_picker_overlays_history.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +expression: open_text +--- +Transcript row 30: content behind the menu +Transcript row 31: content behind the menu +Transcript row 32: content behind the menu +Transcript row 33: content behind the menu +Transcript row 34: content behind the menu +› /model choose what model and reasoning effort to use + /memories configure memory use and generation + /mention mention a file + /mcp list configured MCP tools; use /mcp verbose for details + +› /m + + gpt-test default · /tmp/project + model high · fast diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap new file mode 100644 index 0000000000..a9c65b5c3f --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 8342 +expression: transcript +--- + /permissions - choose what Codex is allowed to do + /model - choose what model and reasoning effort to use + /review - review any changes and find issues + + +› retained prompt + + + + [Image #1] + [Image #2] + +› selected prompt [Image #1] + + +• Conversation reverted to this point. File changes are unchanged. diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index deaabb88be..e8603718c9 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -750,8 +750,8 @@ See the Codex keymap documentation for supported actions and examples." keymap: runtime_keymap, key_chord_matcher: KeyChordMatcher::default(), transcript_cells: Vec::new(), - transcript_view: Default::default(), 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, diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index cedb7343f3..68d083dc0c 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -456,7 +456,7 @@ mod tests { let mut rendered = Vec::new(); while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() { rendered.extend( - cell.display_lines(/*width*/ 120) + cell.transcript_lines(/*width*/ 120) .iter() .map(render_line_text), ); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index de601a8531..6ea13f3632 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -37,8 +37,8 @@ pub(super) async fn make_test_app() -> App { pending_server_profiles: HashMap::new(), file_search, transcript_cells: Vec::new(), - transcript_view: Default::default(), 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, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ec1a34191b..f2570d119d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -21,6 +21,8 @@ mod backend_banner_startup_tests; mod background_exit_tests; #[path = "tests/background_task_defaults_tests.rs"] mod background_task_defaults_tests; +#[path = "tests/browsing_pagination_tests.rs"] +mod browsing_pagination_tests; #[path = "tests/buffered_replay.rs"] mod buffered_replay; #[path = "tests/connector_policy.rs"] @@ -33,6 +35,8 @@ mod fork_workspace_roots_tests; mod fresh_sparkle_tests; #[path = "tests/key_chords.rs"] mod key_chords; +#[path = "tests/local_command_scroll_tests.rs"] +mod local_command_scroll_tests; #[path = "tests/luna_reserve_recovery_tests.rs"] mod luna_reserve_recovery_tests; #[path = "tests/mcp_startup.rs"] @@ -80,7 +84,6 @@ mod stream_animation_tests; mod thread_usage; #[path = "tests/transcript_composer.rs"] mod transcript_composer; - #[path = "tests/transcript_selection.rs"] mod transcript_selection; #[path = "tests/turn_submission.rs"] @@ -457,7 +460,7 @@ fn bypass_hook_trust_startup_warning_snapshot() { "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation." .to_string(), ) - .display_lines(/*width*/ 80), + .transcript_lines(/*width*/ 80), ); assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered); @@ -481,7 +484,7 @@ async fn cyber_model_auto_review_notice_snapshot() -> Result<()> { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; - let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + let rendered = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); assert_app_snapshot!("cyber_model_auto_review_notice", rendered); Ok(()) } @@ -5096,11 +5099,11 @@ async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> { ); assert_snapshot!( format!("{input_footer}\n{approval_footer}\n{cleared_footer}"), - @" - Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close - Side from main thread · main needs approval · ctrl+/ to switch · ctrl+c to close - Side from main thread · ctrl+/ to switch · ctrl+c to close - " + @r" + Side from main thread · main needs input · ctrl+/ to switch · ctrl+c to close + Side from main thread · main needs approval · ctrl+/ to switch · ctrl+c to close + Side from main thread · ctrl+/ to switch · ctrl+c to close + " ); Ok(()) @@ -5975,8 +5978,8 @@ async fn make_test_app() -> App { runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), - transcript_view: crate::transcript_view::TranscriptView::default(), 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, @@ -6078,8 +6081,8 @@ pub(super) async fn make_test_app_with_channels() -> ( runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), - transcript_view: crate::transcript_view::TranscriptView::default(), 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, @@ -8147,8 +8150,11 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res let source_thread_id = ThreadId::from_string(&source_thread_id)?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; - // Leave the oldest prompt on an unloaded page. + // Bound fixture hydration independently from the owned viewport exercised below. + // Inline hydration's row cap leaves the oldest prompt on an unloaded page. let mut local_settings = crate::local_settings::LocalSettings::from(&config); + local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; + local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never; local_settings.tui.terminal_resize_reflow_max_rows = Some(2); let started = app_server .resume_thread( @@ -8211,6 +8217,48 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res .await? .goal; 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.transcript_view + .scroll(&app.transcript_cells, /*rows*/ -1); + app.render_owned_transcript(&mut tui, size)?; + // Freeze a visible selection before the same thread's authoritative history changes. + let selected_row = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) + .content() + .chunks(usize::from(size.width)) + .position(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .contains("selected prompt") + }) + .expect("selected prompt should be visible") as u16; + for (kind, column) in [ + ( + crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + 2, + ), + ( + crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left), + 8, + ), + ] { + app.transcript_view.handle_mouse( + crossterm::event::MouseEvent { + kind, + column, + row: selected_row, + modifiers: KeyModifiers::NONE, + }, + &app.transcript_cells, + ); + } + assert!( + app.transcript_view + .selected_text(&app.transcript_cells) + .is_some() + ); let prompt = crate::chatwidget::UserMessage { text: "selected prompt [Image #1]".to_string(), local_images: vec![crate::bottom_pane::LocalImageAttachment { @@ -8260,8 +8308,9 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res assert_eq!( app_server .thread_read(source_thread_id, /*include_turns*/ true) - .await?, - before + .await? + .turns, + before.turns ); } app.app_server_target = crate::AppServerTarget::Embedded; @@ -8303,6 +8352,32 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res _ => {} } } + assert_eq!( + ( + app.transcript_view.is_following(), + app.transcript_view.selected_text(&app.transcript_cells) + ), + (true, None), + ); + let bottom = app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let transcript = buffer + .content() + .chunks(usize::from(size.width)) + .take(usize::from(bottom.y.saturating_sub(/*rhs*/ 1))) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + let stable_cwd = "/tmp/project"; + let cwd = test_path_buf(stable_cwd).display().to_string(); + let transcript = transcript.replace(&cwd, &format!("{stable_cwd: Res .iter() .any(|line| line.contains("Thread forked from")) ); - // Editing the first visible prompt leaves the unloaded prefix intact. + // Editing the first visible prompt also discards search state from the removed window. + app.transcript_view.begin_search(); + assert!(app.transcript_view.is_search_active()); app.chat_widget.restore_thread_input_state( /*input_state*/ None, crate::chatwidget::ThreadInputStateRestoreMode { @@ -8376,6 +8453,8 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; } } + assert!(!app.transcript_view.is_search_active()); + assert!(app.transcript_view.is_following()); assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); assert_eq!( app.chat_widget.composer_text_with_pending(), @@ -8422,6 +8501,7 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res app_server.thread_goal_get(source_thread_id).await?.goal, Some(goal) ); + tui.set_owned_screen(/*owned*/ false)?; app_server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index c9b4fbe5b3..27c1a3cd20 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -3,6 +3,8 @@ use super::*; use crate::app::reconnect::ReconnectPresentation; use crate::app::reconnect::reconnect; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; use pretty_assertions::assert_eq; use serde_json::json; use tokio::net::TcpListener; @@ -24,6 +26,9 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> let pending_profile = !recovered_queue && resume_error_code == -32600; let (mut app, mut events, mut ops) = make_test_app_with_channels().await; app.local_settings.tui.show_server_version_notice = notice_enabled; + // Reconstructed widgets should use the same hint on every test terminal. + app.local_settings.tui.keymap.chat.edit_queued_message = + Some(KeybindingsSpec::One(KeybindingSpec("alt-up".into()))); let id = ThreadId::new(); let cwd = app.config.cwd.clone(); app.config.model = Some("gpt-test".into()); @@ -732,7 +737,7 @@ pub(super) async fn drain_history( Ok(lines_to_single_string( &app.transcript_cells .iter() - .flat_map(|cell| cell.display_lines(/*width*/ 80)) + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) .collect::>(), )) } diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index 57cb0afdbe..706e2c0057 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -221,11 +221,17 @@ async fn daemon_ctrl_c_shows_background_exit_menu_and_escape_dismisses_it() -> R open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; assert!(!app.chat_widget.no_modal_or_popup_active()); - for width in [40, 90] { - let rendered = render_bottom_popup(&app.chat_widget, width); - assert!(rendered.contains("enter select · esc back"), "{rendered}"); - insta::assert_snapshot!(format!("running_task_exit_picker_{width}"), rendered); - } + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" + Task is still running + Choose what happens to the current task. + + + › 1. Cancel task Stop the current task and stay in Codex + 2. Run in background Exit Codex and leave the task running + 3. Exit Stop the current task and exit Codex + + enter select · esc back + "); app.handle_key_event( &mut tui, @@ -484,7 +490,7 @@ async fn daemon_ctrl_c_hides_background_exit_for_running_background_side_thread( prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; - assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @" + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" Task is still running Choose what happens to the current task. diff --git a/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs b/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs new file mode 100644 index 0000000000..a52491902b --- /dev/null +++ b/codex-rs/tui/src/app/tests/browsing_pagination_tests.rs @@ -0,0 +1,157 @@ +//! Prompt browsing pages past a partial answer without losing its selection or exit origin. + +use super::pagination_completion_tests::completed_history_app; +use super::session_lifecycle_requests::start_recording_app_server; +use super::*; +use crate::history_cell::PlainHistoryCell; +use crate::pager_overlay::TranscriptHistoryState; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn browsing_loads_before_the_oldest_prompt_after_a_partial_answer() -> Result<()> { + for cancel_before_completion in [false, true] { + 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, + /*failed_thread_name*/ None, + ) + .await?; + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + // A transport page may begin inside the preceding answer, well before its first prompt. + app.transcript_cells = (0..30) + .map(|index| { + Arc::new(PlainHistoryCell::new(vec![ + format!("Earlier answer fragment {index}").into(), + ])) as Arc + }) + .collect(); + app.transcript_cells + .extend(crate::thread_transcript::thread_items_to_transcript_cells( + Some(thread_id), + &app.config.cwd, + started.turns.iter().flat_map(|turn| turn.items.clone()), + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + )); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.scrollback_has_older_history = app_server.has_older_history(thread_id); + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); + app.transcript_view.history = TranscriptHistoryState::Partial; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let size = tui.terminal.size()?; + app.render_owned_transcript(&mut tui, size)?; + for _ in 0..2 { + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::Esc.into()), + ) + .await?; + } + assert_eq!( + ( + app.backtrack.overlay_preview_active, + app.backtrack.nth_user_message + ), + (true, 0) + ); + assert!(!app.transcript_view.needs_history(&app.transcript_cells)); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + assert_eq!( + app.transcript_view.history, + TranscriptHistoryState::LoadingOlder + ); + let first_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), event_rx.recv()) + .await? + .expect("older history completion"); + let AppEvent::OlderThreadHistoryLoaded { cursor, .. } = first_event else { + panic!("expected an older history completion") + }; + // Repeated navigation while loading must keep a single request in flight. + for code in [KeyCode::Left, KeyCode::Char('h')] { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(code.into())) + .await?; + } + assert!(app_server.is_older_history_page_pending(thread_id, &cursor)); + assert!(event_rx.try_recv().is_err()); + // Complete only an answer item first: browsing must continue to a page with a prompt. + let first_page = app_server + .thread_items_page( + thread_id, + /*turn_id*/ None, + Some(cursor.clone()), + /*limit*/ 1, + ) + .await?; + let next_cursor = first_page.next_cursor.clone().expect("older prompt page"); + if cancel_before_completion { + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::Esc.into()), + ) + .await?; + } + app.handle_older_history_page( + &mut tui, + &mut app_server, + thread_id, + &cursor, + Ok(first_page), + ) + .await?; + assert_eq!( + app_server.is_older_history_page_pending(thread_id, &next_cursor), + !cancel_before_completion + ); + if cancel_before_completion { + assert_eq!( + ( + app.backtrack.overlay_preview_active, + app.transcript_view.is_following() + ), + (false, true) + ); + } else { + let final_event = + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), event_rx.recv()) + .await? + .expect("older prompt completion"); + Box::pin(app.handle_event(&mut tui, &mut app_server, final_event)).await?; + assert_eq!( + (app.backtrack.nth_user_message, app.transcript_view.history), + (2, TranscriptHistoryState::Complete) + ); + for (code, selected) in [(KeyCode::Left, 1), (KeyCode::Char('h'), 0)] { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(code.into())) + .await?; + assert_eq!(app.backtrack.nth_user_message, selected); + } + app.render_owned_transcript(&mut tui, size)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let text = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("Oldest prompt"), "{text}"); + } + tui.set_owned_screen(/*owned*/ false)?; + app_server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs index a5647c8a58..192fbbae5c 100644 --- a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs +++ b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs @@ -226,7 +226,7 @@ async fn commands_can_precede_the_sparkle_but_inserted_or_typed_drafts_cannot() .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert!( render_bottom_popup(&app.chat_widget, /*width*/ 80) - .contains("customize shortcuts with /keymap") + .contains("Keyboard shortcuts") ); } "commands" => { diff --git a/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs b/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs new file mode 100644 index 0000000000..138f0042d8 --- /dev/null +++ b/codex-rs/tui/src/app/tests/local_command_scroll_tests.rs @@ -0,0 +1,258 @@ +//! Explicit local commands return to their output without making background updates +//! steal the reader's position in the owned transcript. + +use super::*; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use pretty_assertions::assert_eq; + +fn hold_older_history(app: &mut App, tui: &mut crate::tui::Tui) { + app.insert_history_cell( + tui, + Box::new(PlainHistoryCell::new( + (0..6) + .map(|index| Line::from(format!("older {index}"))) + .collect(), + )), + ); + app.transcript_view + .jump_to_entry(&app.transcript_cells, /*index*/ 0); + assert!(!app.transcript_view.is_following()); +} + +fn transcript_buffer(app: &mut App) -> Buffer { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3, + ); + let mut buffer = Buffer::empty(area); + app.transcript_view + .render(area, &mut buffer, &app.transcript_cells); + buffer +} + +fn submit_local_command(app: &mut App, command: &str) { + app.chat_widget.apply_external_edit(command.to_owned()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + // Closing the slash popup can require one Enter before submitting. Stop as + // soon as the command is consumed so a newly opened picker stays untouched. + for _ in 0..2 { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + if app.chat_widget.composer_text_with_pending().is_empty() { + break; + } + } + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); +} + +#[tokio::test] +async fn local_command_and_inline_error_reveal_their_history_output() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = start_config_write_test_app_server(&app).await?; + tui.set_owned_screen(/*owned*/ true)?; + + for (command, output) in [ + ("/status", "/status"), + ("/keymap invalid", "Usage: /keymap [debug]"), + ] { + hold_older_history(&mut app, &mut tui); + while events.try_recv().is_ok() {} + submit_local_command(&mut app, command); + let follow = events.try_recv().expect("command follow event"); + assert_matches!(&follow, AppEvent::FollowTranscript); + app.handle_event(&mut tui, &mut server, follow).await?; + let mut inserted = false; + while let Ok(event) = events.try_recv() { + if matches!(&event, AppEvent::InsertHistoryCell(_)) { + app.handle_event(&mut tui, &mut server, event).await?; + inserted = true; + } + } + assert!(inserted, "{command} should emit local history output"); + assert!(app.transcript_view.is_following()); + let cell = app.transcript_cells.last().expect("local command output"); + assert!(lines_to_single_string(&cell.display_lines(/*width*/ 80)).contains(output)); + } + + let visible = transcript_buffer(&mut app) + .content + .chunks(/*chunk_size*/ 80) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(visible, @" + older 5 + + ■ Usage: /keymap [debug] + "); + tui.set_owned_screen(/*owned*/ false)?; + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn copy_shortcut_reveals_its_feedback_without_changing_the_draft() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = start_config_write_test_app_server(&app).await?; + tui.set_owned_screen(/*owned*/ true)?; + hold_older_history(&mut app, &mut tui); + app.chat_widget + .apply_external_edit("draft preserved".to_string()); + while events.try_recv().is_ok() {} + + // No response avoids the host clipboard while exercising the real shortcut route. + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)), + ) + .await?; + assert!(!app.transcript_view.is_following()); + // The default copy shortcut is a chord; its prefix alone must not move the viewport. + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyCode::Char('o').into()), + ) + .await?; + while let Ok(event) = events.try_recv() { + app.handle_event(&mut tui, &mut server, event).await?; + } + + assert!(app.transcript_view.is_following()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "draft preserved" + ); + let visible = transcript_buffer(&mut app) + .content + .chunks(/*chunk_size*/ 80) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(visible, @" + older 5 + + ■ No agent response to copy + "); + tui.set_owned_screen(/*owned*/ false)?; + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn dynamic_service_tier_command_returns_to_latest() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + set_fast_mode_test_catalog(&mut app.chat_widget); + app.chat_widget.set_model("gpt-5.4"); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = start_config_write_test_app_server(&app).await?; + tui.set_owned_screen(/*owned*/ true)?; + hold_older_history(&mut app, &mut tui); + while events.try_recv().is_ok() {} + + submit_local_command(&mut app, "/fast"); + + let follow = events.try_recv().expect("service tier follow event"); + assert_matches!(&follow, AppEvent::FollowTranscript); + app.handle_event(&mut tui, &mut server, follow).await?; + assert!(app.transcript_view.is_following()); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, + AppEvent::PersistServiceTierSelection { service_tier: Some(tier) } + if tier == ServiceTier::Fast.request_value() + )) + ); + + tui.set_owned_screen(/*owned*/ false)?; + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn usage_picker_opens_analytics_without_moving_the_background_transcript() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let startup_request = app.chat_widget.start_rate_limit_reset_startup_check(); + assert!(app.chat_widget.finish_rate_limit_reset_hint_refresh( + startup_request, + Vec::new(), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None, + }), + )); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = start_config_write_test_app_server(&app).await?; + tui.set_owned_screen(/*owned*/ true)?; + hold_older_history(&mut app, &mut tui); + while events.try_recv().is_ok() {} + + submit_local_command(&mut app, "/usage"); + let follow = events.try_recv().expect("usage command follow event"); + assert_matches!(&follow, AppEvent::FollowTranscript); + app.handle_event(&mut tui, &mut server, follow).await?; + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("View analytics")); + while events.try_recv().is_ok() {} + app.transcript_view + .jump_to_entry(&app.transcript_cells, /*index*/ 0); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let open = events.try_recv().expect("usage picker action"); + assert_matches!(&open, AppEvent::OpenAnalytics { view: None }); + let held = transcript_buffer(&mut app); + app.handle_event(&mut tui, &mut server, open).await?; + assert!(matches!(app.overlay.as_ref(), Some(Overlay::Analytics(_)))); + assert_eq!(transcript_buffer(&mut app), held); + assert!(!app.transcript_view.is_following()); + + tui.set_owned_screen(/*owned*/ false)?; + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn background_history_insertion_keeps_the_visible_reading_rows() -> Result<()> { + let (mut app, _events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = start_config_write_test_app_server(&app).await?; + tui.set_owned_screen(/*owned*/ true)?; + hold_older_history(&mut app, &mut tui); + let held = transcript_buffer(&mut app); + + app.handle_event( + &mut tui, + &mut server, + AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![ + "background task finished".into(), + ]))), + ) + .await?; + + assert_eq!(transcript_buffer(&mut app), held); + assert!(!app.transcript_view.is_following()); + assert_eq!(app.transcript_cells.len(), 2); + tui.set_owned_screen(/*owned*/ false)?; + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/model_defaults_tests.rs b/codex-rs/tui/src/app/tests/model_defaults_tests.rs index 2b69291c2a..477fdc1421 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -166,7 +166,7 @@ async fn model_default_saves_report_server_outcomes_and_target_server_profile() let messages = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event { AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) } _ => None, }) diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 181cbabc02..b1742d99a5 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1927,6 +1927,9 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac #[tokio::test] async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> { let (mut app, codex_home) = make_history_test_app().await?; + // The inline scrollback row cap fixes the page boundary around the review marker. + 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(100); let thread_id = create_fake_paginated_rollout( codex_home.path(), @@ -2123,7 +2126,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> } #[tokio::test] -async fn transcript_home_loads_every_older_history_page() -> Result<()> { +async fn transcript_alt_beginning_loads_every_older_history_page() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); @@ -2260,7 +2263,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { app.handle_backtrack_overlay_event( &mut tui, &mut app_server, - TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::ALT)), ) .await?; while app_server.has_older_history(thread_id) { @@ -2279,7 +2282,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { .any(|line| line.to_string().contains("history output 0")) })); let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else { - panic!("expected transcript overlay after Home navigation"); + panic!("expected transcript overlay after beginning navigation"); }; let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12, @@ -2542,6 +2545,9 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri 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()); + // Keep initial hydration within the inline scrollback budget so refill has work to do. + 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(8); let thread_id = create_history_rollout( &app.config, @@ -2675,7 +2681,10 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri assert!(!partial.contains("This is a test announcement")); assert!(!partial.contains('%')); - overlay.set_history_state(crate::pager_overlay::TranscriptHistoryState::LoadingOlder); + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + )?; let loading = render_overlay(overlay); assert!(loading.contains("Loading earlier messages…")); assert!(!loading.contains("OpenAI Codex")); @@ -3810,7 +3819,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook AppEvent::InsertHistoryCell(cell) => Some(cell), _ => None, }) - .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 200))) + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 200))) .collect::>() }; let change = |thread_id, path: &str| AppEvent::ChangeWorkingDirectory { @@ -4334,16 +4343,16 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { render_bottom_popup(&app.chat_widget, /*width*/ 80) .replace(&root_thread_id.to_string(), "[root]") .replace(&child_thread_id.to_string(), "[child]"), - @" - Subagents - Select an agent to watch. ⌥+← previous, ⌥+→ next. + @r###" + Subagents + Select an agent to watch. ⌥+← previous, ⌥+→ next. - › 1. • Main [default] (current) [root] - 2. • /root/worker [child] + › 1. • Main [default] (current) [root] + 2. • /root/worker [child] - enter select · esc back - " + enter select · esc back + "### ); assert_eq!(take_backfill_counts(&requests), (0, 0)); tokio::time::timeout(Duration::from_secs(5), started_rx).await??; @@ -4631,9 +4640,11 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re let current_id = current.session.thread_id; app.enqueue_primary_thread_session(current.session, current.turns) .await?; - let thread = owner + let mut thread = owner .thread_read(thread_id, /*include_turns*/ false) .await?; + // Keep the age label stable while the server exercises failure recovery. + thread.updated_at = chrono::Utc::now().timestamp() - 7 * 24 * 60 * 60; let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); view.handle_paste("Keep this draft".into()); app.chat_widget.show_bottom_pane_view(Box::new(view)); diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap deleted file mode 100644 index e4a854e8fa..0000000000 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_40.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/app/tests/background_exit_tests.rs -assertion_line: 227 -expression: rendered ---- - Task is still running - Choose what happens to the current - task. - - -› 1. Cancel task - 2. Run in background - 3. Exit - - enter select · esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap deleted file mode 100644 index 741e3aecb3..0000000000 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__running_task_exit_picker_90.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/app/tests/background_exit_tests.rs -expression: rendered ---- - Task is still running - Choose what happens to the current task. - - -› 1. Cancel task Stop the current task and stay in Codex - 2. Run in background Exit Codex and leave the task running - 3. Exit Stop the current task and exit Codex - - enter select · esc back diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 56a6aeff01..2f77ae53b6 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1182,7 +1182,7 @@ async fn fresh_startup_notice_follows_session_attachment() { }) .collect::>(); assert!(cells.len() > 1, "session history should precede the notice"); - insta::assert_snapshot!(lines_to_single_string(&cells.last().unwrap().display_lines(/*width*/ 80)), @"⚠ Older server notice"); + insta::assert_snapshot!(lines_to_single_string(&cells.last().unwrap().transcript_lines(/*width*/ 80)), @"⚠ Older server notice"); assert_eq!(app.pending_server_version_notice, None); } @@ -1215,7 +1215,7 @@ async fn remote_overview_startup_hides_disabled_older_server_notice() -> Result< let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); - insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n"), @" Agent command center + insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::>().join("\n"), @" Agent command center 0 need input 0 working 0 ready"); app.chat_widget.remote_connection = crate::status::remote_connection::remote_connection_status_value( @@ -1233,7 +1233,7 @@ async fn remote_overview_startup_hides_disabled_older_server_notice() -> Result< app.local_settings.tui.show_server_version_notice = true; app.refresh_server_version_overview_notice("2.1.0"); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); - insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n"), @" Service v2.0.0 < Codex CLI v2.1.0 + insta::assert_snapshot!(rendered.lines().take(/*n*/ 2).collect::>().join("\n"), @" Service v2.0.0 < Codex CLI v2.1.0 0 need input 0 working 0 ready"); app.pending_server_version_notice = Some(crate::status::remote_connection::ServerVersionNotice { diff --git a/codex-rs/tui/src/app/user_verification_tests.rs b/codex-rs/tui/src/app/user_verification_tests.rs index 4b7b9f8bf9..a3c6e1985e 100644 --- a/codex-rs/tui/src/app/user_verification_tests.rs +++ b/codex-rs/tui/src/app/user_verification_tests.rs @@ -283,7 +283,7 @@ async fn run_verification_rpc_scenario(scenario: RpcScenario) -> color_eyre::Res panic!("expected warning history cell"); }; let rendered = cell - .display_lines(/*width*/ 80) + .transcript_lines(/*width*/ 80) .iter() .map(|line| { line.spans diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index c78c7b87bf..f4d38d1bf6 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -275,8 +275,12 @@ pub(crate) enum AppEvent { ReviewMisalignment(Arc), ContinueMisalignment(Arc), CloseMisalignmentReview, - /// Open the daemon-wide overview of recent and locally retained root sessions. + /// Open the live command center for recent and locally retained root sessions. OpenAgentsOverview, + /// Create an empty thread from the command center. + NewAgentsOverviewSession { + cwd: Option, + }, /// Update the daemon-wide overview after a background thread listing finishes. AgentsOverviewThreadsLoaded { request_id: Uuid, @@ -286,10 +290,6 @@ pub(crate) enum AppEvent { SelectAgentsOverviewThread { thread_id: ThreadId, }, - /// Open an empty session in the selected checkout. - NewAgentsOverviewSession { - cwd: Option, - }, /// Create an empty session in a worktree from the selected project's default branch. NewAgentsOverviewWorktree { cwd: Option, @@ -1069,6 +1069,10 @@ pub(crate) enum AppEvent { /// resize-reflow tail renderer. BeginThreadSwitchHistoryReplayBuffer, + /// Resume following the transcript after an explicit local command submission. + /// Background output and later refreshes must preserve the user's reading position. + FollowTranscript, + InsertHistoryCell(Box), /// Move visible completed voice captions into history in one app event. diff --git a/codex-rs/tui/src/bottom_pane/actionable_banner.rs b/codex-rs/tui/src/bottom_pane/actionable_banner.rs index 88da2f59a0..5bcdbfd8f7 100644 --- a/codex-rs/tui/src/bottom_pane/actionable_banner.rs +++ b/codex-rs/tui/src/bottom_pane/actionable_banner.rs @@ -227,6 +227,7 @@ impl BottomPane { pub(super) fn handle_inline_banner_key(&mut self, key: KeyEvent) -> bool { // Draft input, completion menus, and paste bursts retain all of their normal keys. if !self.composer_is_empty() + || (key.code == KeyCode::Esc && self.composer.shortcut_overlay_visible()) || self.composer.popup_active() || self.composer.is_in_paste_burst() || self.composer_should_handle_vim_insert_escape(key) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e873af7a38..31bf96e783 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,11 +1,16 @@ //! 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. //! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts //! from raw key streams, particularly on Windows. //! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible. +//! Owned transcripts keep persistent status below the composer and hints on a separate final row. +//! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row +//! so input and persistent status stay anchored when help opens or closes. +//! Escape dismisses visible shortcut help before editing, transcript backtracking, or interruption. +//! Transcript interactions borrow the hint footer through `ComposerRenderOptions`; +//! its resolved presentation drives height, painting, and the focused cursor together. //! //! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab //! submit ordinary text without enabling shell mode. @@ -29,6 +34,14 @@ //! //! # Completion and Popup Dismissal //! +//! All completion suggestions render above the composer and preserve its footer. +//! Owned transcript frames overlay suggestions with blank rows above and below, without reserving +//! layout space. Those rows show scroll arrows when suggestions extend beyond the visible menu. +//! Transcript interactions hide suggestions while retaining their query and selection; +//! closing the interaction restores the same draft and completion state. +//! Measurement, painting, and cursor placement share that layout, including clipped views. +//! Unified mention tabs retain their position across filters; Left/Right also works at a bare `@`. +//! //! Popup targeting resolves an editable token range around the cursor and treats atomic text //! elements as hard boundaries. When that range begins immediately after an atomic element, the //! insertion path first adds a horizontal separator and shifts the range so the completion cannot @@ -297,6 +310,7 @@ 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; @@ -355,12 +369,15 @@ mod reconnect; pub(crate) use reconnect::RestrictedInputMode; mod slash_input; mod sparkle; +mod status_surface; mod vim_history; mod vim_search; use self::attachment_state::AttachmentState; use self::draft_state::ComposerMentionBinding; use self::draft_state::DraftState; +pub(crate) use self::footer_state::CommandPopupPlacement; +use self::footer_state::ComposerLayout; pub(crate) use self::footer_state::ComposerRenderOptions; use self::footer_state::FooterState; pub(crate) use self::footer_state::TranscriptFooter; @@ -647,8 +664,6 @@ pub(crate) struct ComposerDraftSnapshot { pub(crate) sparkle_draft: sparkle::SparkleDraft, } -const FOOTER_SPACING_HEIGHT: u16 = 0; - impl ChatComposer { fn slash_input(&self) -> SlashInput<'_> { SlashInput::new( @@ -722,6 +737,8 @@ impl ChatComposer { .primary_hint(KeymapContext::Global, "open_external_editor"), show_transcript_key: default_keymap .primary_hint(KeymapContext::Global, "open_transcript"), + find_transcript_key: default_keymap + .primary_hint(KeymapContext::Global, "find_transcript"), insert_newline_key: footer_insert_newline_key( &default_keymap.editor.insert_newline, use_shift_enter_hint, @@ -977,6 +994,12 @@ impl ChatComposer { self.voice_command_enabled = enabled; } + /// Refresh the event destination when a retained editor survives reconnection. + #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] + pub(crate) fn set_app_event_sender(&mut self, app_event_tx: AppEventSender) { + self.app_event_tx = app_event_tx; + } + /// Replace composer, editor, and footer-hint key bindings from one runtime snapshot. /// /// Submit and queue bindings are cached here because composer dispatch must @@ -996,6 +1019,8 @@ impl ChatComposer { keymap.primary_hint(KeymapContext::Global, "open_external_editor"); self.footer.show_transcript_key = keymap.primary_hint(KeymapContext::Global, "open_transcript"); + self.footer.find_transcript_key = + keymap.primary_hint(KeymapContext::Global, "find_transcript"); self.footer.insert_newline_key = match keymap.primary_hint(KeymapContext::Editor, "insert_newline") { hint @ Some(ShortcutHint::Chord { .. }) => hint, @@ -1069,62 +1094,6 @@ impl ChatComposer { pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { self.windows_degraded_sandbox_active = enabled; } - fn layout_areas(&self, area: Rect) -> [Rect; 4] { - self.layout_areas_with_options(area, ComposerRenderOptions::default()) - } - - fn layout_areas_with_options( - &self, - area: Rect, - options: ComposerRenderOptions<'_>, - ) -> [Rect; 4] { - 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 - .active - .required_height(area.width, footer_total_height); - let popup_constraint = Constraint::Max(popup_height); - let voice_rows = if self.voice_strip.is_some() { 3 } else { 0 }; - let [composer_rect, popup_rect] = - Layout::vertical([Constraint::Min(3 + voice_rows), popup_constraint]).areas(area); - // Keep the draft visible when clipped. - let voice_rows = voice_rows * u16::from(composer_rect.height >= 6); - let mut textarea_rect = composer_rect.inset(Insets::tlbr( - /*top*/ 1 + voice_rows, - LIVE_PREFIX_COLS, - /*bottom*/ 1, - /*right*/ 1u16.saturating_add(textarea_right_reserve), - )); - let remote_images_height = self - .attachments - .remote_image_lines() - .len() - .try_into() - .unwrap_or(u16::MAX) - .min(textarea_rect.height.saturating_sub(1)); - let remote_images_separator = u16::from(remote_images_height > 0); - let consumed = remote_images_height.saturating_add(remote_images_separator); - let remote_images_rect = Rect { - x: textarea_rect.x, - y: textarea_rect.y, - width: textarea_rect.width, - height: remote_images_height, - }; - textarea_rect.y = textarea_rect.y.saturating_add(consumed); - textarea_rect.height = textarea_rect.height.saturating_sub(consumed); - [composer_rect, remote_images_rect, textarea_rect, popup_rect] - } - - fn footer_spacing(footer_hint_height: u16) -> u16 { - if footer_hint_height == 0 { - 0 - } else { - FOOTER_SPACING_HEIGHT - } - } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.cursor_pos_with_options(area, ComposerRenderOptions::default()) @@ -1914,9 +1883,14 @@ impl ChatComposer { } /// Clear the "press again to quit" hint immediately. + /// + /// Key routing calls this before dispatching ordinary input, so unrelated footer modes + /// such as shortcut help must remain available to their own toggle handlers. pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { self.footer.quit_shortcut_expires_at = None; - self.footer.mode = reset_mode_after_activity(self.footer.mode); + if self.footer.mode == FooterMode::QuitShortcutReminder { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + } self.set_has_focus(has_focus); } @@ -2304,7 +2278,7 @@ impl ChatComposer { return (InputResult::None, true); } self.footer.mode = reset_mode_after_activity(self.footer.mode); - let can_switch_search_mode = self.current_editable_at_token().is_some(); + let can_switch_search_mode = self.current_mentions_v2_token_range().is_some(); let ActivePopup::MentionV2(popup) = &mut self.popups.active else { unreachable!(); @@ -2710,15 +2684,6 @@ impl ChatComposer { self.current_editable_prefixed_token_range('@', allow_empty) } - fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { - self.current_editable_at_token_range_with_options(allow_empty) - .map(|(_, token)| token) - } - - fn current_editable_at_token(&self) -> Option { - self.current_editable_at_token_with_options(/*allow_empty*/ false) - } - fn current_mentions_v2_token_range(&self) -> Option<(Range, String)> { if !self.mentions_v2_enabled { return None; @@ -3808,13 +3773,17 @@ impl ChatComposer { .remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea); } - /// Handle empty-prompt agents navigation and the shortcut-overlay toggle. + /// Handle empty-prompt agents navigation and shortcut-overlay toggling or dismissal. /// /// This only toggles when the composer is empty and no paste burst is in /// progress, so typing/pasting `?` still inserts text instead of opening /// help. The bound key list intentionally supports terminal-variant /// modifier reporting (for example `?` vs `shift-?`). fn handle_empty_prompt_shortcut(&mut self, key_event: &KeyEvent) -> bool { + if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(*key_event) { + self.footer.mode = reset_mode_after_activity(self.footer.mode); + return true; + } if key_event.kind != KeyEventKind::Press { return false; } @@ -3866,7 +3835,6 @@ impl ChatComposer { FooterProps { mode, esc_backtrack_hint: self.footer.esc_backtrack_hint, - use_shift_enter_hint: self.footer.use_shift_enter_hint, is_task_running: self.is_task_running, queue_submissions: self.queue_submissions, quit_shortcut_key: self.footer.quit_shortcut_key, @@ -3884,6 +3852,7 @@ impl ChatComposer { external_editor: self.footer.external_editor_key, edit_previous: Some(key_hint::plain(KeyCode::Esc).into()), show_transcript: self.footer.show_transcript_key, + find_transcript: self.footer.find_transcript_key, history_search: self.footer.history_search_key, reasoning_down: self.footer.reasoning_down_key, reasoning_up: self.footer.reasoning_up_key, @@ -4599,36 +4568,6 @@ impl ChatComposer { self.luna_reserve_active = active; true } - - pub(crate) fn desired_height_with_options( - &self, - width: u16, - options: ComposerRenderOptions<'_>, - ) -> u16 { - 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 = - width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve)); - let remote_images_height: u16 = self - .attachments - .remote_image_lines() - .len() - .try_into() - .unwrap_or(u16::MAX); - let remote_images_separator = u16::from(remote_images_height > 0); - self.draft.textarea.desired_height(inner_width) - + remote_images_height - + remote_images_separator - + 2 - + if self.voice_strip.is_some() { 3 } else { 0 } - + self - .popups - .active - .required_height(width, footer_total_height) - } } impl ChatComposer { @@ -4643,25 +4582,70 @@ impl ChatComposer { mask_char: Option, options: ComposerRenderOptions<'_>, ) { - let options = self.resolve_render_options(options); - let [composer_rect, remote_images_rect, textarea_rect, popup_rect] = - self.layout_areas_with_options(area, options); - match &self.popups.active { - ActivePopup::Command(popup) => { - popup.render_ref(popup_rect, buf); + let ComposerLayout { + status, + composer: composer_rect, + remote_images: remote_images_rect, + textarea: textarea_rect, + popup: popup_rect, + footer: footer_rect, + } = self.layout_with_options(area, options); + self.render_status_surface(status, buf); + if self.popups.active.is_above_composer() + && options.command_popup_placement != CommandPopupPlacement::Hidden + { + let popup_rect = if options.command_popup_placement == CommandPopupPlacement::Overlay { + let height = self + .popups + .active + .required_height(composer_rect.width, /*footer_total_height*/ 0) + .min(composer_rect.y.saturating_sub(buf.area.y)); + // Command and unified mention menus can render in a single row. + let height = if matches!( + self.popups.active, + ActivePopup::Command(_) | ActivePopup::MentionV2(_) + ) || height > 2 + { + height + } else { + 0 + }; + Rect::new( + composer_rect.x, + composer_rect.y - height, + composer_rect.width, + height, + ) + } else { + popup_rect + }; + // Overlay rows must erase both glyphs and styles from the transcript underneath. + ratatui::widgets::Clear.render(popup_rect, buf); + match &self.popups.active { + ActivePopup::Command(popup) => popup.render_ref(popup_rect, buf), + ActivePopup::MentionV2(popup) => popup.render_ref(popup_rect, buf), + ActivePopup::File(popup) => popup.render_ref(popup_rect, buf), + ActivePopup::Skill(popup) => popup.render_ref(popup_rect, buf), + ActivePopup::None => { + unreachable!("only suggestion menus use above-composer placement") + } } - ActivePopup::File(popup) => { - popup.render_ref(popup_rect, buf); + } + let transcript_hint_area = inset_footer_hint_area(footer_rect); + match options.footer { + Some(footer) => { + self.render_transcript_footer(transcript_hint_area, buf, footer); } - ActivePopup::Skill(popup) => { - popup.render_ref(popup_rect, buf); + None if self.shortcuts_above_composer(options) => { + let props = self.hint_footer_props(options); + super::shortcut_overlay::render(&props, inset_footer_hint_area(popup_rect), buf); + super::shortcut_overlay::close_hint(&props, transcript_hint_area.width) + .render(transcript_hint_area, buf); } - ActivePopup::MentionV2(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::None => { - let footer_props = self.footer_props(); - let show_cycle_hint = !footer_props.is_task_running + None => { + let footer_props = self.hint_footer_props(options); + let show_cycle_hint = self.status_surface_height(options) == 0 + && !footer_props.is_task_running && self.footer.collaboration_mode_indicator.is_some(); let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), @@ -4679,11 +4663,8 @@ impl ChatComposer { | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; - 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() { + let hint_rect = footer_rect; + 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); @@ -4722,11 +4703,12 @@ impl ChatComposer { } else { None }; - let left_mode_indicator = if status_line_active { - None - } else { - self.footer.collaboration_mode_indicator - }; + let left_mode_indicator = + if status_line_active || self.status_surface_height(options) > 0 { + None + } else { + self.footer.collaboration_mode_indicator + }; let active_footer_hint_override = self.footer.hint_override.as_ref(); let mut left_width = if self.footer.flash_visible() { self.footer @@ -4755,7 +4737,7 @@ impl ChatComposer { Some(side_conversation_context_line(label)) } else if let Some(line) = self.shell_mode_footer_line() { Some(line) - } else if transition_active { + } else if self.status_surface_height(options) > 0 || transition_active { None } else if status_line_active { let full = self.mode_indicator_line(show_cycle_hint); @@ -5013,6 +4995,9 @@ impl ChatComposer { self.cursor_pos_with_options(area, options), buf, ); + if options.footer.is_some_and(|footer| footer.is_interactive) { + buf.set_style(composer_rect, Style::default().dim()); + } } } @@ -5036,6 +5021,10 @@ mod paste_tests; #[path = "chat_composer/snapshot_tests.rs"] mod snapshot_tests; +#[cfg(test)] +#[path = "chat_composer/mentions_layout_tests.rs"] +mod mentions_layout_tests; + #[cfg(test)] mod tests { use super::attachment_state::AttachedImage; @@ -5054,7 +5043,6 @@ 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; @@ -5305,9 +5293,8 @@ mod tests { ); setup(&mut composer); let footer_props = composer.footer_props(); - let footer_lines = footer_height(&footer_props); - let footer_spacing = ChatComposer::footer_spacing(footer_lines); - let height = footer_lines + footer_spacing + 8; + let footer_lines = footer_height(&footer_props, width); + let height = footer_lines + 8; let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal .draw(|f| composer.render(f.area(), f.buffer_mut())) @@ -9424,16 +9411,22 @@ mod tests { // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { - Ok(t) => t, - Err(e) => panic!("Failed to create terminal: {e}"), - }; - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + for (height, snapshot, cursor) in [ + (3, "slash_popup_mo_clipped", (5, 1)), + (5, "slash_popup_mo", (5, 2)), + ] { + let mut terminal = + Terminal::new(TestBackend::new(/*width*/ 60, height)).expect("create terminal"); + terminal + .draw(|f| { + composer.render(f.area(), f.buffer_mut()); + assert_eq!(composer.cursor_pos(f.area()), Some(cursor)); + }) + .expect("draw composer"); - // Visual snapshot should show the slash popup with /model as the first entry. - insta::assert_snapshot!("slash_popup_mo", terminal.backend()); + // The picker sits above the draft; clipping must keep the draft and cursor visible. + insta::assert_snapshot!(snapshot, terminal.backend()); + } } #[test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs index ec966b8d73..5df3803408 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs @@ -1,6 +1,6 @@ -//! Reserves popup and voice-control space around the editable draft. +//! Shared composer geometry keeps rendering, measurement, and the cursor aligned. -use super::ChatComposer; +use super::*; use crate::bottom_pane::voice_strip::VoiceStrip; use crate::bottom_pane::voice_strip::VoiceStripState; use crate::render::renderable::Renderable; @@ -34,3 +34,171 @@ impl ChatComposer { } } } + +impl ChatComposer { + pub(super) fn layout_with_options( + &self, + area: Rect, + options: ComposerRenderOptions<'_>, + ) -> ComposerLayout { + let footer_hint_height = self.footer_hint_height(area.width, options); + // Preserve input and hints before allocating the persistent status row. + let status_height = self.status_surface_height(options).min( + area.height + .saturating_sub(footer_hint_height.saturating_add(/*rhs*/ 3)), + ); + let status = Rect { + y: area + .bottom() + .saturating_sub(footer_hint_height) + .saturating_sub(status_height) + .max(area.y), + height: status_height, + ..area + }; + let area = Rect { + height: area.height - status_height, + ..area + }; + let popup_height = if self.popups.active.is_above_composer() + && options.command_popup_placement != CommandPopupPlacement::AboveComposer + { + footer_hint_height + } else { + self.popups + .active + .required_height(area.width, footer_hint_height) + }; + let voice_rows = if self.voice_strip.is_some() { 3 } else { 0 }; + let shortcuts_above = self.shortcuts_above_composer(options); + let (composer_rect, popup_rect, mut footer_rect) = if shortcuts_above { + let [shortcuts, composer, footer] = Layout::vertical([ + Constraint::Max(footer_height(&self.hint_footer_props(options), area.width)), + Constraint::Min(3 + voice_rows), + Constraint::Length(footer_hint_height), + ]) + .areas(area); + (composer, shortcuts, footer) + } else if self.popups.active.is_above_composer() { + // Preserve the draft first, then its footer, and use remaining space for suggestions. + let [mut composer, mut popup, footer] = Layout::vertical([ + Constraint::Min(3 + voice_rows), + Constraint::Max(popup_height.saturating_sub(footer_hint_height)), + Constraint::Length(footer_hint_height), + ]) + .areas(area); + popup.y = area.y; + composer.y = popup.bottom(); + (composer, popup, footer) + } else { + let [composer, popup] = Layout::vertical([ + Constraint::Min(3 + voice_rows), + Constraint::Max(popup_height), + ]) + .areas(area); + (composer, popup, popup) + }; + footer_rect.y = footer_rect.y.saturating_add(status.height); + // Keep the draft visible when clipped. + let voice_rows = voice_rows * u16::from(composer_rect.height >= 6); + let mut textarea_rect = composer_rect.inset(Insets::tlbr( + /*top*/ 1 + voice_rows, + LIVE_PREFIX_COLS, + /*bottom*/ 1, + /*right*/ 1u16.saturating_add(options.textarea_right_reserve), + )); + let remote_images_height = self + .attachments + .remote_image_lines() + .len() + .try_into() + .unwrap_or(u16::MAX) + .min(textarea_rect.height.saturating_sub(1)); + let remote_images_separator = u16::from(remote_images_height > 0); + let consumed = remote_images_height.saturating_add(remote_images_separator); + let remote_images_rect = Rect { + x: textarea_rect.x, + y: textarea_rect.y, + width: textarea_rect.width, + height: remote_images_height, + }; + textarea_rect.y = textarea_rect.y.saturating_add(consumed); + textarea_rect.height = textarea_rect.height.saturating_sub(consumed); + ComposerLayout { + status, + composer: composer_rect, + remote_images: remote_images_rect, + textarea: textarea_rect, + popup: popup_rect, + footer: footer_rect, + } + } + + pub(crate) fn cursor_pos_with_options( + &self, + area: Rect, + options: ComposerRenderOptions<'_>, + ) -> Option<(u16, u16)> { + let layout = self.layout_with_options(area, options); + if let Some(footer) = options.footer.filter(|footer| footer.is_interactive) { + let area = inset_footer_hint_area(layout.footer); + 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(layout.footer) + .or_else(|| self.history_search_cursor_pos(layout.footer)) + { + return Some(pos); + } + + let state = *self.draft.textarea_state.borrow(); + self.draft + .textarea + .cursor_pos_with_state(layout.textarea, state) + } + + pub(crate) fn desired_height_with_options( + &self, + width: u16, + options: ComposerRenderOptions<'_>, + ) -> u16 { + let footer_hint_height = self.footer_hint_height(width, options); + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; + let inner_width = + width.saturating_sub(COLS_WITH_MARGIN.saturating_add(options.textarea_right_reserve)); + let remote_images_height: u16 = self + .attachments + .remote_image_lines() + .len() + .try_into() + .unwrap_or(u16::MAX); + let remote_images_separator = u16::from(remote_images_height > 0); + self.draft.textarea.desired_height(inner_width) + + remote_images_height + + remote_images_separator + + self.status_surface_height(options) + + if self.shortcuts_above_composer(options) { + footer_height(&self.hint_footer_props(options), width) + } else { + 0 + } + + 2 + + if self.voice_strip.is_some() { 3 } else { 0 } + + if self.popups.active.is_above_composer() + && options.command_popup_placement != CommandPopupPlacement::AboveComposer + { + footer_hint_height + } else { + self.popups + .active + .required_height(width, footer_hint_height) + } + } +} 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 4ffdfd3fdd..937e584f1d 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,122 +1,155 @@ //! 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. +//! One resolved layout supplies the status, input, popup, and hint rectangles to each consumer. +//! Slash-command and unified mention suggestions preserve passive footer content while keeping input. +//! Interactive transcript footers keep focus over nonempty composer hints. Their owner projects +//! actual pending chord hints; unrelated hints resume when the transcript interaction closes. +//! While the transcript owns input, the composer is dimmed and yields its cursor to the footer. +//! Hidden suggestions retain their query and selection until their input owner returns. +//! A pending quit contributes a derived release hint without changing stored footer state. +//! Shortcut help occupies the space above the composer and keeps its close hint on the final row. +//! Passive transcript hints retain the shortcuts entry when it fits beside the complete hint. use std::time::Instant; +use super::super::footer::footer_height; +use super::super::footer::shows_passive_footer_line; +use super::ActivePopup; +use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Text; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; 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. +/// Resolved rectangles shared by painting and cursor placement, without retained layout state. +pub(super) struct ComposerLayout { + pub(super) status: Rect, + pub(super) composer: Rect, + pub(super) remote_images: Rect, + pub(super) textarea: Rect, + pub(super) popup: Rect, + pub(super) footer: Rect, +} + +/// Per-frame transcript content; the composer remains the only footer layout 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) text: Text<'static>, pub(crate) cursor_column: Option, pub(crate) is_interactive: bool, } -/// Borrowed presentation shared by composer measurement, painting, and cursor placement. +/// Whether composer suggestions reserve space, cover transcript rows, or stay hidden. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum CommandPopupPlacement { + #[default] + AboveComposer, + Overlay, + /// Retain suggestions while another surface owns input above the draft. + Hidden, +} + +/// A borrowed presentation shared by measurement, painting, and cursor placement. #[derive(Clone, Copy, Default)] pub(crate) struct ComposerRenderOptions<'a> { pub(crate) textarea_right_reserve: u16, + /// Keep configured status below the composer while hints occupy the final row. + pub(crate) separate_status_line: bool, + pub(crate) command_popup_placement: CommandPopupPlacement, 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>( + pub(crate) 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 - ))) + if (self.popups.active.is_above_composer() + && options.command_popup_placement != CommandPopupPlacement::Hidden + && footer.is_interactive) + || self + .footer + .hint_override + .as_ref() + .is_some_and(|items| items.is_empty() || !footer.is_interactive) + || self.history_search.is_some() + || self.draft.textarea.vim_query().is_some() + || self.quit_shortcut_hint_visible() + { + return false; + } + footer.is_interactive || shows_passive_footer_line(&self.footer_props()) }); 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) + && matches!(self.popups.active, 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 shortcuts_above_composer(&self, options: ComposerRenderOptions<'_>) -> bool { + self.shortcut_overlay_visible() && options.footer.is_none() } - 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(super) fn footer_hint_height(&self, width: u16, options: ComposerRenderOptions<'_>) -> u16 { + if self.shortcuts_above_composer(options) { + return 1; } + options + .footer + .map_or_else( + || { + self.custom_footer_height() + .unwrap_or_else(|| footer_height(&self.hint_footer_props(options), width)) + }, + |footer| footer.text.height().try_into().unwrap_or(u16::MAX), + ) + .max(u16::from(options.separate_status_line)) + } + + pub(super) fn render_transcript_footer( + &self, + hint_area: Rect, + buf: &mut Buffer, + footer: &TranscriptFooter, + ) { + let mut text = footer.text.clone(); + if footer.cursor_column.is_none() + && self.footer.flash_visible() + && let Some(flash) = &self.footer.flash + && let Some(line) = text.lines.last_mut() + { + *line = flash.line.clone(); + } + if !footer.is_interactive + && !self.footer.flash_visible() + && self.footer_mode() == FooterMode::ComposerEmpty + && !self.is_in_paste_burst() + && let Some(key) = self.footer.toggle_shortcuts_key + && let Some(line) = text.lines.last_mut() + { + let mut shortcuts = Line::default(); + if line.width() > 0 { + shortcuts.push_span(" · ".dim()); + } + shortcuts.extend(key.spans()); + shortcuts.push_span(" shortcuts".dim()); + if line.width() + shortcuts.width() <= usize::from(hint_area.width) { + line.extend(shortcuts.spans); + } + } + Paragraph::new(text).render(hint_area, buf); } pub(crate) fn footer_flash_delay(&self) -> Option { @@ -152,6 +185,7 @@ pub(super) struct FooterState { pub(super) active_agent_label: Option, pub(super) external_editor_key: Option, pub(super) show_transcript_key: Option, + pub(super) find_transcript_key: Option, pub(super) insert_newline_key: Option, pub(super) queue_key: Option, pub(super) toggle_shortcuts_key: Option, @@ -190,3 +224,7 @@ impl FooterState { }) } } + +#[cfg(test)] +#[path = "footer_state_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs new file mode 100644 index 0000000000..5db883b7e6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state_tests.rs @@ -0,0 +1,148 @@ +//! Footer replacement keeps the existing layout and the focused editor's cursor together. + +use super::*; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::footer::inset_footer_hint_area; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::unbounded_channel; + +fn composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("MODEL STATUS"))); + composer +} + +fn render( + composer: &ChatComposer, + footer: Option<&TranscriptFooter>, +) -> (Buffer, Option<(u16, u16)>) { + let options = composer.resolve_render_options(ComposerRenderOptions { + footer, + ..ComposerRenderOptions::default() + }); + let width = 60; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + composer.desired_height_with_options(width, options), + ); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + (buffer, composer.cursor_pos_with_options(area, options)) +} + +fn 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") +} + +#[test] +fn retained_command_suggestions_yield_paint_and_cursor_to_find() { + let mut composer = composer(); + composer.draft.textarea.insert_str("/"); + composer.sync_popups(); + let original = render(&composer, /*footer*/ None); + let footer = TranscriptFooter { + text: vec![ + Line::from("Find: needle"), + Line::from("Enter next · Esc close"), + ] + .into(), + cursor_column: Some(8), + is_interactive: true, + }; + let options = composer.resolve_render_options(ComposerRenderOptions { + footer: Some(&footer), + command_popup_placement: CommandPopupPlacement::Hidden, + ..ComposerRenderOptions::default() + }); + let mut frames = Vec::new(); + for (width, height) in [(60, 8), (24, 8), (12, 3)] { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + let cursor = composer.cursor_pos_with_options(area, options); + let footer_area = + inset_footer_hint_area(composer.layout_with_options(area, options).footer); + assert_eq!( + cursor, + (footer_area.width > 8 && footer_area.height > 0) + .then_some((footer_area.x + 8, footer_area.y)) + ); + assert!(matches!(composer.popups.active, ActivePopup::Command(_))); + assert_eq!(composer.draft.textarea.text(), "/"); + frames.push(format!( + "{width}x{height}, cursor {cursor:?}\n{}", + text(&buffer) + )); + } + insta::assert_snapshot!( + "find_hides_retained_command_suggestions", + frames.join("\n\n") + ); + assert_eq!(render(&composer, /*footer*/ None), original); +} + +#[test] +fn copying_from_find_preserves_the_query_above_selection_feedback() { + let mut composer = composer(); + let footer = TranscriptFooter { + text: vec![ + Line::from("Find: needle"), + Line::from("Ctrl+C copy · Esc clear selection"), + ] + .into(), + cursor_column: None, + is_interactive: true, + }; + composer.show_footer_flash( + "Copied selection to clipboard".into(), + Duration::from_secs(/*secs*/ 3), + ); + let (buffer, cursor) = render(&composer, Some(&footer)); + assert_eq!(cursor, None); + insta::assert_snapshot!("transcript_find_copy_footer", text(&buffer)); +} + +#[test] +fn interactive_query_keeps_focus_over_underlying_hints_but_empty_override_wins() { + let mut composer = composer(); + let footer = TranscriptFooter { + text: vec![ + Line::from("Find: needle"), + Line::from("Enter next · Esc close"), + ] + .into(), + cursor_column: Some(12), + is_interactive: true, + }; + let expected = render(&composer, Some(&footer)); + composer.set_footer_hint_override(Some(vec![("ctrl+c".to_string(), "quit".to_string())])); + assert_eq!(render(&composer, Some(&footer)), expected); + composer.set_footer_hint_override(Some(Vec::new())); + assert_eq!( + render(&composer, Some(&footer)), + render(&composer, /*footer*/ None) + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 528a75c4a7..9b14b3973f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -25,8 +25,6 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; @@ -34,7 +32,6 @@ use ratatui::text::Span; use super::super::chat_composer_history::HistorySearchDirection; use super::super::chat_composer_history::HistorySearchResult; -use super::super::footer::footer_height; use super::super::footer::reset_mode_after_activity; use super::super::textarea::VimPersistentState; use super::ActivePopup; @@ -476,28 +473,8 @@ impl ChatComposer { /// The cursor tracks the end of the footer query rather than the textarea preview. If the /// footer area is collapsed or too narrow, the x coordinate is clamped inside the hint rect so /// terminal backends do not receive an off-screen cursor position. - pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + pub(super) fn history_search_cursor_pos(&self, hint_rect: Rect) -> Option<(u16, u16)> { self.history_search.as_ref()?; - let [_, _, _, popup_rect] = self.layout_areas(area); - if popup_rect.is_empty() { - return None; - } - - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_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 hint_rect.is_empty() { return None; } @@ -999,7 +976,9 @@ mod tests { } let area = Rect::new(0, 0, 60, 8); - let [_, _, textarea_rect, _] = composer.layout_areas(area); + let textarea_rect = composer + .layout_with_options(area, Default::default()) + .textarea; let mut buf = Buffer::empty(area); composer.render(area, &mut buf); let x = textarea_rect.x; @@ -1018,7 +997,9 @@ mod tests { ); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area); + let accepted_textarea_rect = composer + .layout_with_options(area, Default::default()) + .textarea; let mut accepted_buf = Buffer::empty(area); composer.render(area, &mut accepted_buf); for offset in 0..3 { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs index f8c6c0d9c5..9039124265 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs @@ -2,6 +2,7 @@ use super::super::super::chat_composer_history::HistoryEntry; use super::super::ChatComposer; +use super::super::ComposerRenderOptions; use super::super::InputResult; use super::super::LARGE_PASTE_CHAR_THRESHOLD; use crate::app_event_sender::AppEventSender; @@ -71,7 +72,10 @@ fn history_search_large_paste_clamps_cursor() { composer.handle_paste("x".repeat(usize::from(u16::MAX) + 1)); let terminal = render_composer(&composer, /*width*/ 80); - let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into()); + let cursor = composer.cursor_pos_with_options( + terminal.size().unwrap().into(), + ComposerRenderOptions::default(), + ); assert_eq!(cursor, Some((79, 4))); insta::assert_snapshot!( "history_search_large_paste_cursor", @@ -115,7 +119,10 @@ fn history_search_paste_shows_separators_and_matches_original_query() { assert_eq!(composer.draft.textarea.text(), "draft"); let terminal = render_composer(&composer, /*width*/ 70); - let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into()); + let cursor = composer.cursor_pos_with_options( + terminal.size().unwrap().into(), + ComposerRenderOptions::default(), + ); assert_eq!(cursor, Some((31, 4))); insta::assert_snapshot!( "history_search_pasted_separators", diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs new file mode 100644 index 0000000000..897f662f61 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs @@ -0,0 +1,241 @@ +//! Production mention overlays retain the draft, status footer and editor cursor across scopes. + +use super::*; +use pretty_assertions::assert_eq; +use ratatui::layout::Size; +use tokio::sync::mpsc::unbounded_channel; + +fn composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some("MODEL STATUS".into())); + composer.set_mentions_v2_enabled(/*enabled*/ true); + composer.set_plugin_mentions(Some( + (0..12) + .map(|index| PluginCapabilitySummary { + config_name: format!("plugin{index:02}@test"), + display_name: format!("Plugin{index:02}"), + plugin_namespace: None, + description: Some(format!("Plugin description {index:02}")), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }) + .collect(), + )); + composer.set_text_content("@".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor("@".len()); + composer +} + +fn render_overlay( + composer: &ChatComposer, + size: Size, + footer: &TranscriptFooter, +) -> (Buffer, Rect, Option<(u16, u16)>) { + let options = composer.resolve_render_options(ComposerRenderOptions { + command_popup_placement: CommandPopupPlacement::Overlay, + footer: Some(footer), + ..Default::default() + }); + let mut buffer = Buffer::empty(Rect::new( + /*x*/ 0, + /*y*/ 0, + size.width, + size.height, + )); + let height = composer + .desired_height_with_options(size.width, options) + .min(size.height); + let area = Rect::new(/*x*/ 0, size.height - height, size.width, height); + for y in 0..area.y { + Line::from(format!("Transcript row {y:02}: behind the menu").magenta()) + .render(Rect::new(/*x*/ 0, y, size.width, /*height*/ 1), &mut buffer); + } + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + ( + buffer, + area, + composer.cursor_pos_with_options(area, options), + ) +} + +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") +} + +#[test] +fn mention_scopes_overlay_history_without_moving_composer_or_footer() { + let footer = TranscriptFooter { + text: "TRANSCRIPT STATUS".into(), + cursor_column: None, + is_interactive: false, + }; + for (width, height) in [(80, 32), (40, 32), (80, 12), (80, 7), (80, 6), (80, 5)] { + let mut composer = composer(); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let size = Size::new(width, height); + let (closed, composer_area, cursor) = render_overlay(&composer, size, &footer); + let composer_start = closed.index_of(composer_area.x, composer_area.y); + composer.popups.dismissed_mention_token = None; + composer.sync_popups(); + let mut menu_row = None; + for scope in ["all", "filesystem", "plugins"] { + let (open, area, open_cursor) = render_overlay(&composer, size, &footer); + assert_eq!((area, open_cursor), (composer_area, cursor)); + assert_eq!( + &open.content[composer_start..], + &closed.content[composer_start..], + ); + if (1..=2).contains(&composer_area.y) && scope != "filesystem" { + assert!(buffer_text(&open).contains("Plugin00")); + } + if height == 32 { + assert_eq!( + &open.content[..usize::from(width)], + &closed.content[..usize::from(width)] + ); + let current_menu_row = (0..composer_area.y).find(|y| { + (0..width) + .map(|x| open[(x, *y)].symbol()) + .collect::() + .contains("Mentions") + }); + assert!(current_menu_row.is_some()); + if let Some(previous) = menu_row { + assert_eq!(current_menu_row, Some(previous)); + } + menu_row = current_menu_row; + // The popup owns exactly its measured height, including both overflow rows. + let ActivePopup::MentionV2(popup) = &composer.popups.active else { + panic!("expected unified mentions"); + }; + let first_popup_row = composer_area.y - popup.calculate_required_height(width); + assert!(current_menu_row.is_some_and(|row| row >= first_popup_row)); + assert!((first_popup_row..composer_area.y).all(|y| { + !(0..width) + .map(|x| open[(x, y)].symbol()) + .collect::() + .contains("Transcript row") + })); + } + if (width, height) == (80, 32) && scope == "all" { + insta::assert_snapshot!("mention_scopes_overlay_history", buffer_text(&open)); + } + composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + } + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + render_overlay(&composer, size, &footer), + (closed, composer_area, cursor) + ); + } +} + +#[test] +fn mention_menu_reserves_height_above_composer_and_owns_cursor_over_interactive_footer() { + let composer = composer(); + let footer = TranscriptFooter { + text: "Find: needle".into(), + cursor_column: Some(6), + is_interactive: true, + }; + let options = composer.resolve_render_options(ComposerRenderOptions { + footer: Some(&footer), + ..Default::default() + }); + assert!(options.footer.is_none()); + let width = 80; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + composer.desired_height_with_options(width, options), + ); + let mut buffer = Buffer::empty(area); + composer.render_with_options(area, &mut buffer, /*mask_char*/ None, options); + let ComposerLayout { + composer: surface, + textarea, + popup, + footer, + .. + } = composer.layout_with_options(area, options); + assert_eq!((popup.bottom(), surface.bottom()), (surface.y, footer.y)); + assert_eq!( + composer.cursor_pos_with_options(area, options), + Some((3, textarea.y)) + ); + insta::assert_snapshot!("mention_menu_above_composer", buffer_text(&buffer)); +} + +#[test] +fn fallback_completions_keep_the_draft_cursor_and_footer_anchored() { + let footer = TranscriptFooter { + text: "TRANSCRIPT STATUS".into(), + cursor_column: None, + is_interactive: false, + }; + for (width, height) in [(80, 24), (40, 16), (20, 5)] { + for token in ["$", "@"] { + let mut composer = composer(); + composer.popups.active = ActivePopup::None; + composer.draft.textarea.set_text_clearing_elements(token); + let size = Size::new(width, height); + let (closed, composer_area, cursor) = render_overlay(&composer, size, &footer); + composer.popups.active = if token == "$" { + ActivePopup::Skill(SkillPopup::new(vec![MentionItem { + display_name: "Example skill".into(), + description: Some("Review this checkout".into()), + insert_text: "$example".into(), + search_terms: vec!["example".into()], + path: None, + category_tag: None, + sort_rank: 1, + }])) + } else { + let mut popup = FileSearchPopup::new(); + popup.set_matches( + "", + vec![codex_file_search::FileMatch { + score: 1, + path: "src/example.rs".into(), + root: "/repo".into(), + match_type: codex_file_search::MatchType::File, + indices: None, + }], + ); + ActivePopup::File(popup) + }; + let (open, area, open_cursor) = render_overlay(&composer, size, &footer); + assert_eq!((area, open_cursor), (composer_area, cursor)); + let start = open.index_of(area.x, area.y); + assert_eq!(&open.content[start..], &closed.content[start..]); + if height > 5 { + assert!(buffer_text(&open).contains(if token == "$" { + "Example skill" + } else { + "src/example.rs" + })); + } + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs index da2dfd908b..2e8f951062 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs @@ -113,13 +113,18 @@ pub(super) enum ActivePopup { } impl ActivePopup { + /// Shared suggestion menus render above the draft and retain its status footer. + pub(super) fn is_above_composer(&self) -> bool { + !matches!(self, Self::None) + } + pub(super) fn required_height(&self, width: u16, footer_total_height: u16) -> u16 { match self { Self::None => footer_total_height, - Self::Command(popup) => popup.calculate_required_height(width), - Self::File(popup) => popup.calculate_required_height(), - Self::Skill(popup) => popup.calculate_required_height(width), - Self::MentionV2(popup) => popup.calculate_required_height(width), + Self::Command(popup) => popup.calculate_required_height(width) + footer_total_height, + Self::File(popup) => popup.calculate_required_height() + footer_total_height, + Self::Skill(popup) => popup.calculate_required_height(width) + footer_total_height, + Self::MentionV2(popup) => popup.calculate_required_height(width) + footer_total_height, } } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap new file mode 100644 index 0000000000..b0436bc381 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__find_hides_retained_command_suggestions.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs +assertion_line: 231 +expression: "frames.join(\"\\n\\n\")" +--- +60x8, cursor Some((10, 6)) + +› / + + + + + Find: needle + Enter next · Esc close + +24x8, cursor Some((10, 6)) + +› / + + + + + Find: needle + Enter next · Esc close + +12x3, cursor None + +› / diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap new file mode 100644 index 0000000000..fc52d4617d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__footer_state__tests__transcript_find_copy_footer.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer/footer_state_tests.rs +expression: text(&buffer) +--- + +› Ask Codex + + Find: needle + Copied selection to clipboard diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__mentions_layout_tests__mention_menu_above_composer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__mentions_layout_tests__mention_menu_above_composer.snap new file mode 100644 index 0000000000..5c3bad0e86 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__mentions_layout_tests__mention_menu_above_composer.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs +expression: buffer_text(&buffer) +--- + Mentions + Files, directories, plugins, skills and tasks + + All Results Filesystem Only Plugins + + Type to search mentions + +› Plugin00 Plugin description 00 Plugin + Plugin01 Plugin description 01 Plugin + Plugin02 Plugin description 02 Plugin + Plugin03 Plugin description 03 Plugin + Plugin04 Plugin description 04 Plugin + Plugin05 Plugin description 05 Plugin + Plugin06 Plugin description 06 Plugin + Plugin07 Plugin description 07 Plugin +↓ + enter/tab insert · esc close · ↑/↓ select · ←/→ filter + +› @ + + MODEL STATUS diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__mentions_layout_tests__mention_scopes_overlay_history.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__mentions_layout_tests__mention_scopes_overlay_history.snap new file mode 100644 index 0000000000..966c47dd65 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__mentions_layout_tests__mention_scopes_overlay_history.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/bottom_pane/chat_composer/mentions_layout_tests.rs +expression: buffer_text(&open) +--- +Transcript row 00: behind the menu +Transcript row 01: behind the menu +Transcript row 02: behind the menu +Transcript row 03: behind the menu +Transcript row 04: behind the menu +Transcript row 05: behind the menu +Transcript row 06: behind the menu +Transcript row 07: behind the menu +Transcript row 08: behind the menu +Transcript row 09: behind the menu +Transcript row 10: behind the menu + Mentions + Files, directories, plugins, skills and tasks + + All Results Filesystem Only Plugins + + Type to search mentions + +› Plugin00 Plugin description 00 Plugin + Plugin01 Plugin description 01 Plugin + Plugin02 Plugin description 02 Plugin + Plugin03 Plugin description 03 Plugin + Plugin04 Plugin description 04 Plugin + Plugin05 Plugin description 05 Plugin + Plugin06 Plugin description 06 Plugin + Plugin07 Plugin description 07 Plugin +↓ + enter/tab insert · esc close · ↑/↓ select · ←/→ filter + +› @ + + TRANSCRIPT STATUS diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap index 3f714c9f94..6697092d6c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__a_fresh_astra_response_waits_for_the_pending_command_to_leave.snap @@ -1,13 +1,15 @@ --- source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +assertion_line: 561 expression: "format!(\"command during startup:\\n{}\\n\\nafter command:\\n{}\", text(&pending),\ntext(&completed))" --- command during startup: +› /status show current session configuration and token usage + /statusline configure which items appear in the status line › /status -› /status show current session configuration and token usage - /statusline configure which items appear in the status line + 100% context left after command: ⠈ ⢀ ⠁ ⠂ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap index d2048569da..c150d7acf6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +assertion_line: 452 expression: "snapshots.join(\"\\n\\n\")" --- expires during help: false @@ -9,18 +10,27 @@ initial: ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ ? for shortcuts 100% context left shortcut help: + Keyboard shortcuts + + Compose Session + / Commands tab Send message + @ Mention files ⌥+, Less reasoning + ! Shell command ⌥+. More reasoning + ctrl+j New line ctrl+c Quit + ctrl+v Paste image + ctrl+g External editor Transcript (open first) + ctrl+r Search history ctrl+t Open transcript + esc esc Edit last message f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥+, / shift+⌥+. Top / latest + + /keymap customize › Ask Codex to do anything - / for commands ! for shell commands - ctrl+j for newline tab to submit message - @ for file paths ctrl+v to paste images - ctrl+g to edit in external editor esc esc to edit previous message - ctrl+r search history ctrl+c to exit - ⌥+, reasoning down ⌥+. reasoning up - ctrl+t to view transcript - - customize shortcuts with /keymap + ? / esc close after closing: ⠁ ⡀ ⠄ ⠄ ⠄ › Ask Codex to do anything ⠈ @@ -34,18 +44,27 @@ initial: ⢀ ⠠ ⠄ ⠠ ⢀ ⠠ ⡀ ? for shortcuts 100% context left shortcut help: + Keyboard shortcuts + + Compose Session + / Commands tab Send message + @ Mention files ⌥+, Less reasoning + ! Shell command ⌥+. More reasoning + ctrl+j New line ctrl+c Quit + ctrl+v Paste image + ctrl+g External editor Transcript (open first) + ctrl+r Search history ctrl+t Open transcript + esc esc Edit last message f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥+, / shift+⌥+. Top / latest + + /keymap customize › Ask Codex to do anything - / for commands ! for shell commands - ctrl+j for newline tab to submit message - @ for file paths ctrl+v to paste images - ctrl+g to edit in external editor esc esc to edit previous message - ctrl+r search history ctrl+c to exit - ⌥+, reasoning down ⌥+. reasoning up - ctrl+t to view transcript - - customize shortcuts with /keymap + ? / esc close after closing: › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap index 5b4cdacb05..7dbc122cdf 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap @@ -1,20 +1,30 @@ --- source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs +assertion_line: 365 expression: "snapshots.join(\"\\n\\n\")" --- shortcut help: + Keyboard shortcuts + + Compose Session + / Commands tab Send message + @ Mention files ⌥+, Less reasoning + ! Shell command ⌥+. More reasoning + ctrl+j New line ctrl+c Quit + ctrl+v Paste image + ctrl+g External editor Transcript (open first) + ctrl+r Search history ctrl+t Open transcript + esc esc Edit last message f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥+, / shift+⌥+. Top / latest + + /keymap customize › Ask Codex to do anything - / for commands ! for shell commands - ctrl+j for newline tab to submit message - @ for file paths ctrl+v to paste images - ctrl+g to edit in external editor esc esc to edit previous message - ctrl+r search history ctrl+c to exit - ⌥+, reasoning down ⌥+. reasoning up - ctrl+t to view transcript - - customize shortcuts with /keymap + ? / esc close after /model selects Astra: ⠈ ⢀ ⠁ ⠂ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__persistent_status_and_messages.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__persistent_status_and_messages.snap new file mode 100644 index 0000000000..c35c2d93e5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__persistent_status_and_messages.snap @@ -0,0 +1,59 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +expression: "states.join(\"\\n---\\n\")" +--- + + + + + + + + + +› Ask Codex + + MODEL · ~/project · Context 20% used Plan mode + ? for shortcuts +--- + + + + + + + + + +› Ask Codex + + MODEL · ~/project · Context 20% used Plan mode + Copied selection +--- + + + + + + + + + +› Ask Codex + + MODEL · ~/project · Context 20% used Plan mode + Ctrl+X waiting for chord +--- + + + + + + + + + +› Continue the task + + MODEL · ~/project · Context 20% used Plan mode + tab to queue message diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap new file mode 100644 index 0000000000..e733a7bf45 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +expression: text +--- + + + + + + + + + Keyboard shortcuts + + Compose + / Commands + @ Mention files + ! Shell command + ctrl+j New line + ctrl+v Paste image + ctrl+g External editor + ctrl+r Search history + esc esc Edit last message + + Session + tab Send message + ⌥+, Less reasoning + ⌥+. More reasoning + ctrl+c Quit + + Transcript (open first) + ctrl+t Open transcript + f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥+, / shift+⌥+. Top / latest + + /keymap customize + +› Ask Codex + + MODEL · ~/project · Context 20% used + ? / esc close diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap new file mode 100644 index 0000000000..6664b64357 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +expression: text +--- + + + + + + + + + Keyboard shortcuts + + Compose Session Transcript (open first) + / Commands tab Send message ctrl+t Open transcript + @ Mention files ⌥+, Less reasoning f3 Find text + ! Shell command ⌥+. More reasoning pgup / pgdn Scroll + ctrl+j New line ctrl+c Quit ctrl+space Start selection + ctrl+v Paste image ctrl+home / ctrl+end Top / latest + ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest + ctrl+r Search history + esc esc Edit last message + + /keymap customize + +› Ask Codex + + MODEL · ~/project · Context 20% used + ? / esc close diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap new file mode 100644 index 0000000000..0e69fbe2aa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__slash_popup_footer_wide.snap @@ -0,0 +1,39 @@ +--- +source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs +assertion_line: 344 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 8 }, + content: [ + " /model choose what model and reasoning effort to use ", + "› /memories configure memory use and generation ", + " /mention mention a file ", + " /mcp list configured MCP tools; use /mcp verbose for details ", + " ", + "› /m ", + " ", + " model · high · fast ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 58, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED, + x: 48, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | REVERSED, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 68, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 1, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} 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 e6ad3005e2..8c39017d12 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,19 +55,19 @@ fn draw(composer: &ChatComposer, width: u16, now: Instant) -> (Buffer, Rect) { width, composer.desired_height(width), ); - let [padding, _, textarea, _] = composer.layout_areas(area); + let layout = composer.layout_with_options(area, Default::default()); let phase = composer.sparkle.phase.replace(Phase::Unarmed); let mut buffer = Buffer::empty(area); composer.render(area, &mut buffer); composer.sparkle.phase.set(phase); composer.render_sparkle_at( - padding, - textarea, + layout.composer, + layout.textarea, composer.cursor_pos(area), now, &mut buffer, ); - (buffer, textarea) + (buffer, layout.textarea) } fn text(buffer: &Buffer) -> String { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs new file mode 100644 index 0000000000..14fe0a9dbc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs @@ -0,0 +1,69 @@ +//! Separate persistent transcript context from the composer's transient hint row. +//! Measurement, rendering, and cursor placement share the same reserved status rectangle. +//! Shortcut help grows above the input, preserving status and close rows below it even when clipped. + +use super::*; + +impl ChatComposer { + pub(super) fn status_surface_height(&self, options: ComposerRenderOptions<'_>) -> u16 { + u16::from(options.separate_status_line && self.footer.status_line_enabled) + } + + pub(super) fn hint_footer_props(&self, options: ComposerRenderOptions<'_>) -> FooterProps { + let mut props = self.footer_props(); + if self.status_surface_height(options) > 0 { + props.status_line_enabled = false; + props.status_line_value = None; + props.active_agent_label = None; + } + props + } + + pub(super) fn render_status_surface(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + let mut props = self.footer_props(); + // Persistent context is independent of help, queue, search, and quit modes. + props.mode = FooterMode::ComposerHasDraft; + props.is_task_running = false; + let line = passive_footer_status_line(&props); + let right = self.mode_indicator_line(/*show_cycle_hint*/ false); + let right_width = right + .as_ref() + .map_or(/*default*/ 0, |line| line.width() as u16); + let width = max_left_width_for_right(area, right_width) + .unwrap_or_else(|| inset_footer_hint_area(area).width); + let transition = self + .effort_status_line_transition + .as_ref() + .filter(|transition| !transition.is_finished()); + let line = if let Some(transition) = transition { + transition.render_line(line.as_ref(), width) + } else { + line + }; + if let Some(line) = line { + render_footer_line( + area, + buf, + truncate_line_with_ellipsis_if_overflow(line, usize::from(width)), + ); + } + if let Some(right) = right { + render_context_right(area, buf, &right); + } + if let Some(url) = self.footer.status_line_hyperlink_url.as_deref() { + mark_underlined_hyperlink(buf, area, url); + } + if transition.is_some() + && let Some(frame_requester) = &self.frame_requester + { + frame_requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK); + } + } +} + +#[cfg(test)] +#[path = "status_surface_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs new file mode 100644 index 0000000000..f2b1b0dc68 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -0,0 +1,364 @@ +//! Persistent status survives transient footer modes and suggestion overlays. + +use super::*; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::unbounded_channel; + +fn composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("MODEL · ~/project · Context 20% used"))); + composer +} + +fn render( + composer: &ChatComposer, + width: u16, + footer: Option<&TranscriptFooter>, +) -> (String, Option<(u16, u16)>) { + render_with_height(composer, width, footer, /*height*/ None) +} + +fn render_with_height( + composer: &ChatComposer, + width: u16, + footer: Option<&TranscriptFooter>, + height: Option, +) -> (String, Option<(u16, u16)>) { + let options = composer.resolve_render_options(ComposerRenderOptions { + separate_status_line: true, + command_popup_placement: CommandPopupPlacement::Overlay, + footer, + ..ComposerRenderOptions::default() + }); + let height = height.unwrap_or_else(|| composer.desired_height_with_options(width, options)); + let area = Rect::new(/*x*/ 0, /*y*/ 8, width, height); + let mut buf = Buffer::empty(Rect::new(/*x*/ 0, /*y*/ 0, width, area.bottom())); + composer.render_with_options(area, &mut buf, /*mask_char*/ None, options); + let text = buf + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + (text, composer.cursor_pos_with_options(area, options)) +} + +#[test] +fn passive_activity_keeps_shortcuts_only_when_the_complete_hint_fits() { + let mut composer = composer(); + let mut line = Line::from(key_hint::key_label_spans("ctrl+o t/f4")); + line.push_span(" inspect activity".dim()); + let mut footer = TranscriptFooter { + text: line.into(), + cursor_column: None, + is_interactive: false, + }; + for (width, expected) in [ + (80, "ctrl+o t/f4 inspect activity · ? shortcuts"), + (40, "ctrl+o t/f4 inspect activity"), + ] { + let (text, _) = render(&composer, width, Some(&footer)); + assert_eq!(text.lines().last().map(str::trim), Some(expected)); + } + footer.is_interactive = true; + let (text, _) = render(&composer, /*width*/ 80, Some(&footer)); + assert_eq!( + text.lines().last().map(str::trim), + Some("ctrl+o t/f4 inspect activity") + ); + footer.is_interactive = false; + composer.footer.toggle_shortcuts_key = None; + let (text, _) = render(&composer, /*width*/ 80, Some(&footer)); + assert_eq!( + text.lines().last().map(str::trim), + Some("ctrl+o t/f4 inspect activity") + ); +} + +#[test] +fn shortcut_help_stays_above_the_prompt_and_bottom_status() { + let mut composer = composer(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + for (width, height, snapshot) in [ + (130, None, Some("shortcut_help_above_wide")), + (80, None, None), + (40, None, Some("shortcut_help_above_narrow")), + (80, Some(12), None), + ] { + let (text, cursor) = render_with_height(&composer, width, /*footer*/ None, height); + let rows: Vec<_> = text.lines().collect(); + let help_row = rows + .iter() + .position(|line| line.contains("Keyboard shortcuts")) + .unwrap(); + let prompt_row = rows + .iter() + .position(|line| line.contains("Ask Codex")) + .unwrap(); + assert!(help_row < prompt_row); + assert!( + rows[..prompt_row] + .iter() + .any(|line| line.trim() == "/keymap customize") + ); + assert_eq!(text.matches("? / esc close").count(), 1); + assert_eq!( + ( + rows.last().map(|line| line.trim()), + rows[rows.len() - 2].trim(), + cursor, + ), + ( + Some("? / esc close"), + "MODEL · ~/project · Context 20% used", + Some((2, prompt_row as u16)) + ), + ); + assert_eq!(prompt_row + 3, rows.len() - 1); + if let Some(name) = snapshot { + insta::assert_snapshot!(name, text); + } + } +} + +#[test] +fn shortcut_help_keeps_bottom_anchored_composer_status_and_cursor_stable() { + let (width, available_height) = (40, 12); + let mut composer = composer(); + let mut geometry = Vec::new(); + for event in [ + None, + Some(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)), + Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ] { + if let Some(event) = event { + composer.handle_key_event(event); + } + let options = composer.resolve_render_options(ComposerRenderOptions { + separate_status_line: true, + ..ComposerRenderOptions::default() + }); + let height = composer + .desired_height_with_options(width, options) + .min(available_height); + let area = Rect::new(/*x*/ 3, 80 - height, width, height); + let ComposerLayout { + composer: surface, + footer, + status, + .. + } = composer.layout_with_options(area, options); + geometry.push(( + surface, + status, + footer, + composer.cursor_pos_with_options(area, options), + )); + } + assert_eq!(geometry, vec![geometry[0]; 3]); +} + +#[test] +fn shortcut_help_keeps_input_and_close_hint_visible_when_height_is_tiny() { + for status_enabled in [true, false] { + let mut composer = composer(); + composer.set_status_line_enabled(status_enabled); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + let options = composer.resolve_render_options(ComposerRenderOptions { + separate_status_line: true, + ..ComposerRenderOptions::default() + }); + for height in 0..=7 { + let area = Rect::new(/*x*/ 3, /*y*/ 5, /*width*/ 40, height); + let mut buf = Buffer::empty(area); + composer.render_with_options(area, &mut buf, /*mask_char*/ None, options); + if let Some((x, y)) = composer.cursor_pos_with_options(area, options) { + assert!(x >= area.x && x < area.right() && y >= area.y && y < area.bottom()); + } + if height >= 4 { + let ComposerLayout { + composer: surface, + footer, + .. + } = composer.layout_with_options(area, options); + assert_eq!( + (surface.height, footer.height, footer.bottom()), + (3, 1, area.bottom()) + ); + let last_row = (area.x..area.right()) + .map(|x| buf[(x, area.bottom() - 1)].symbol()) + .collect::(); + assert_eq!(last_row.trim(), "? / esc close"); + } + } + } +} + +#[test] +fn shortcut_help_close_row_uses_runtime_binding_and_narrow_fallback() { + let chord = Some(key_hint::ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::ctrl(KeyCode::Char('h')), + }); + for (width, binding, expected) in [ + (80, chord, "ctrl+x ctrl+h / esc close"), + (22, chord, "esc close"), + (80, None, "esc close"), + ] { + let mut composer = composer(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.footer.toggle_shortcuts_key = binding; + let (text, _) = render_with_height( + &composer, + width, + /*footer*/ None, + /*height*/ Some(8), + ); + assert_eq!(text.lines().last().map(str::trim), Some(expected)); + assert_eq!(text.matches("close").count(), 1); + assert_eq!(text.matches("/keymap customize").count(), 1); + } +} + +#[test] +fn status_survives_hints_feedback_and_queue() { + let mut composer = composer(); + composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan)); + let mut states = Vec::new(); + states.push(render(&composer, /*width*/ 64, /*footer*/ None).0); + composer.footer.mode = FooterMode::ShortcutOverlay; + composer.show_footer_flash("Copied selection".into(), Duration::from_secs(/*secs*/ 3)); + states.push(render(&composer, /*width*/ 64, /*footer*/ None).0); + composer.footer.flash = None; + composer.footer.mode = FooterMode::ComposerEmpty; + composer.set_footer_hint_override(Some(vec![("Ctrl+X".into(), "waiting for chord".into())])); + states.push(render(&composer, /*width*/ 64, /*footer*/ None).0); + composer.set_footer_hint_override(/*items*/ None); + composer.set_text_content("Continue the task".into(), Vec::new(), Vec::new()); + composer.set_task_running(/*running*/ true); + states.push(render(&composer, /*width*/ 64, /*footer*/ None).0); + for popup in [ + ActivePopup::File(FileSearchPopup::new()), + ActivePopup::Skill(SkillPopup::new(Vec::new())), + ] { + composer.popups.active = popup; + let (text, _) = render(&composer, /*width*/ 64, /*footer*/ None); + assert!(text.contains("MODEL · ~/project · Context 20% used")); + assert_eq!(text.matches("Plan mode").count(), 1); + } + for state in &states { + assert_eq!(state.matches("Plan mode").count(), 1); + assert!(state.contains("MODEL · ~/project · Context 20% used")); + } + insta::assert_snapshot!("persistent_status_and_messages", states.join("\n---\n")); +} + +#[test] +fn transcript_search_keeps_status_and_query_cursor() { + let composer = composer(); + let footer = TranscriptFooter { + text: vec![ + Line::from("Find: needle"), + Line::from("Enter next · Esc close"), + ] + .into(), + cursor_column: Some(8), + is_interactive: true, + }; + let (text, cursor) = render(&composer, /*width*/ 48, Some(&footer)); + assert_eq!(cursor, Some((10, text.lines().count() as u16 - 2))); + assert!(text.contains("MODEL · ~/project · Context 20% used")); +} + +#[test] +fn input_history_search_uses_the_final_row() { + let mut composer = composer(); + composer.begin_history_search(); + let (text, cursor) = render(&composer, /*width*/ 48, /*footer*/ None); + assert_eq!( + cursor.map(|(_, y)| y), + Some(text.lines().count() as u16 - 1) + ); +} + +#[test] +fn slash_popup_preserves_footer_and_selection_style() { + let (tx, _rx) = unbounded_channel::(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ true, + ); + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some("model · high · fast".into())); + for ch in ['/', 'm'] { + composer.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + for (width, clipped) in [(80, false), (32, false), (80, true)] { + let height = if clipped { + 6 + } else { + composer.desired_height(width) + }; + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + let ComposerLayout { + composer: surface, + textarea, + popup, + footer, + .. + } = composer.layout_with_options(area, ComposerRenderOptions::default()); + assert_eq!(popup.bottom(), surface.y); + assert_eq!(surface.bottom(), footer.y); + assert_eq!(composer.cursor_pos(area), Some((4, textarea.y))); + let footer_text: String = (0..width).map(|x| buf[(x, height - 1)].symbol()).collect(); + assert!(footer_text.contains("model · high · fast")); + if !clipped && width == 80 { + insta::assert_snapshot!("slash_popup_footer_wide", format!("{buf:?}")); + } + } + + let footer = TranscriptFooter { + text: "transcript status".into(), + cursor_column: None, + is_interactive: false, + }; + let options = composer.resolve_render_options(ComposerRenderOptions { + footer: Some(&footer), + ..Default::default() + }); + assert!(options.footer.is_some()); + let width = 80; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + composer.desired_height_with_options(width, options), + ); + let mut buf = Buffer::empty(area); + composer.render_with_options(area, &mut buf, /*mask_char*/ None, options); + let footer_text: String = (0..width) + .map(|x| buf[(x, area.bottom() - 1)].symbol()) + .collect(); + assert!(footer_text.contains("transcript status")); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs index 35d5a5d18d..90b5ccd420 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search.rs @@ -13,9 +13,8 @@ impl ChatComposer { canceled } - pub(super) fn vim_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + pub(super) fn vim_search_cursor_pos(&self, mut footer: Rect) -> Option<(u16, u16)> { let query = self.draft.textarea.vim_query()?; - let [_, _, _, mut footer] = self.layout_areas(area); footer.y = footer.bottom().saturating_sub(1); footer.height = footer.height.min(1); query.cursor_pos(inset_footer_hint_area(footer)) diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 64f97630b8..76a13b744c 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -5,6 +5,9 @@ //! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like //! `ChatWidget` (which decides when quit/interrupt is allowed). //! +//! The shortcut reference lives in `shortcut_overlay`; its responsive layout is shared by +//! footer measurement and rendering so resizing does not clip a differently measured layout. +//! //! Some footer content is time-based rather than event-based, such as the "press again to quit" //! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is //! otherwise idle. @@ -41,6 +44,7 @@ //! In short: `single_line_footer_layout` chooses *what* best fits, and the two //! render helpers choose whether to draw the chosen line or the default //! `FooterProps` mapping. +use super::shortcut_overlay; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::ShortcutHint; @@ -69,7 +73,6 @@ use ratatui::widgets::Widget; pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, - pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) queue_submissions: bool, pub(crate) collaboration_modes_enabled: bool, @@ -116,6 +119,7 @@ pub(crate) struct FooterKeyHints { pub(crate) external_editor: Option, pub(crate) edit_previous: Option, pub(crate) show_transcript: Option, + pub(crate) find_transcript: Option, pub(crate) history_search: Option, pub(crate) reasoning_down: Option, pub(crate) reasoning_up: Option, @@ -132,6 +136,7 @@ impl FooterKeyHints { external_editor: Some(key_hint::ctrl(KeyCode::Char('g')).into()), edit_previous: Some(key_hint::plain(KeyCode::Esc).into()), show_transcript: Some(key_hint::ctrl(KeyCode::Char('t')).into()), + find_transcript: Some(key_hint::plain(KeyCode::F(3)).into()), history_search: Some(key_hint::ctrl(KeyCode::Char('r')).into()), reasoning_down: Some(key_hint::alt(KeyCode::Char(',')).into()), reasoning_up: Some(key_hint::alt(KeyCode::Char('.')).into()), @@ -225,7 +230,11 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { } } -pub(crate) fn footer_height(props: &FooterProps) -> u16 { +pub(crate) fn footer_height(props: &FooterProps, width: u16) -> u16 { + if props.mode == FooterMode::ShortcutOverlay { + return shortcut_overlay::lines(props, width.saturating_sub(FOOTER_INDENT_COLS as u16)).len() + as u16; + } let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => false, @@ -278,6 +287,10 @@ pub(crate) fn render_footer_from_props( show_shortcuts_hint: bool, show_queue_hint: bool, ) { + if props.mode == FooterMode::ShortcutOverlay { + shortcut_overlay::render(props, inset_footer_hint_area(area), buf); + return; + } Paragraph::new(prefix_lines( footer_from_props_lines( props, @@ -780,18 +793,7 @@ fn footer_from_props_lines( key_hints, )] } - FooterMode::ShortcutOverlay => { - let state = ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - queue_submissions: props.queue_submissions, - is_wsl: props.is_wsl, - collaboration_modes_enabled: props.collaboration_modes_enabled, - key_hints, - }; - shortcut_overlay_lines(state) - } + FooterMode::ShortcutOverlay => shortcut_overlay::lines(props, u16::MAX), FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], FooterMode::ComposerHasDraft => { let state = LeftSideState { @@ -922,17 +924,6 @@ pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static Line::from(spans) } -#[derive(Clone, Copy, Debug)] -struct ShortcutsState { - use_shift_enter_hint: bool, - esc_backtrack_hint: bool, - is_task_running: bool, - queue_submissions: bool, - is_wsl: bool, - collaboration_modes_enabled: bool, - key_hints: FooterKeyHints, -} - fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { let mut line = Line::from(key.spans()); line.push_span(" again to quit".set_style(secondary_text_style())); @@ -954,129 +945,6 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { } } -fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut commands = Line::from(""); - let mut shell_commands = Line::from(""); - let mut newline = Line::from(""); - let mut queue_message_tab = Line::from(""); - let mut file_paths = Line::from(""); - let mut paste_image = Line::from(""); - let mut external_editor = Line::from(""); - let mut edit_previous = Line::from(""); - let mut history_search = Line::from(""); - let mut quit = Line::from(""); - let mut show_transcript = Line::from(""); - let mut change_mode = Line::from(""); - let mut reasoning_down = Line::from(""); - let mut reasoning_up = Line::from(""); - - for descriptor in SHORTCUTS { - if let Some(text) = descriptor.overlay_entry(state) { - match descriptor.id { - ShortcutId::Commands => commands = text, - ShortcutId::ShellCommands => shell_commands = text, - ShortcutId::InsertNewline => newline = text, - ShortcutId::QueueMessageTab => queue_message_tab = text, - ShortcutId::FilePaths => file_paths = text, - ShortcutId::PasteImage => paste_image = text, - ShortcutId::ExternalEditor => external_editor = text, - ShortcutId::EditPrevious => edit_previous = text, - ShortcutId::HistorySearch => history_search = text, - ShortcutId::Quit => quit = text, - ShortcutId::ShowTranscript => show_transcript = text, - ShortcutId::ChangeMode => change_mode = text, - ShortcutId::ReasoningDown => reasoning_down = text, - ShortcutId::ReasoningUp => reasoning_up = text, - } - } - } - - let mut ordered = vec![ - commands, - shell_commands, - newline, - queue_message_tab, - file_paths, - paste_image, - external_editor, - edit_previous, - history_search, - quit, - reasoning_down, - reasoning_up, - ]; - if change_mode.width() > 0 { - ordered.push(change_mode); - } - ordered.push(show_transcript); - let mut lines = build_columns(ordered); - if let Some(key) = state.key_hints.agents { - lines.push(Line::from(vec![ - key.display_label() - .fg(crate::style::accent_color_on(/*background*/ None)) - .not_bold() - .not_dim(), - " for agents (empty prompt)".set_style(secondary_text_style()), - ])); - } - lines.push(Line::from("")); - lines.push(Line::from(vec![ - "customize shortcuts with ".set_style(secondary_text_style()), - "/keymap" - .fg(crate::style::accent_color_on(/*background*/ None)) - .not_bold() - .not_dim(), - ])); - lines -} - -fn build_columns(entries: Vec>) -> Vec> { - if entries.is_empty() { - return Vec::new(); - } - - const COLUMNS: usize = 2; - const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; - const COLUMN_GAP: usize = 4; - - let rows = entries.len().div_ceil(COLUMNS); - let target_len = rows * COLUMNS; - let mut entries = entries; - if entries.len() < target_len { - entries.extend(std::iter::repeat_n( - Line::from(""), - target_len - entries.len(), - )); - } - - let mut column_widths = [0usize; COLUMNS]; - - for (idx, entry) in entries.iter().enumerate() { - let column = idx % COLUMNS; - column_widths[column] = column_widths[column].max(entry.width()); - } - - for (idx, width) in column_widths.iter_mut().enumerate() { - *width += COLUMN_PADDING[idx]; - } - - entries - .chunks(COLUMNS) - .map(|chunk| { - let mut line = Line::from(""); - for (col, entry) in chunk.iter().enumerate() { - line.extend(entry.spans.clone()); - if col < COLUMNS - 1 { - let target_width = column_widths[col]; - let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; - line.push_span(Span::from(" ".repeat(padding))); - } - } - line.set_style(secondary_text_style()) - }) - .collect() -} - pub(crate) fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { if let Some(percent) = percent { let percent = percent.clamp(0, 100); @@ -1097,273 +965,6 @@ pub(crate) fn context_window_line(percent: Option, used_tokens: Option ]) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShortcutId { - Commands, - ShellCommands, - InsertNewline, - QueueMessageTab, - FilePaths, - PasteImage, - ExternalEditor, - EditPrevious, - HistorySearch, - Quit, - ShowTranscript, - ChangeMode, - ReasoningDown, - ReasoningUp, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ShortcutBinding { - key: KeyBinding, - condition: DisplayCondition, -} - -impl ShortcutBinding { - fn matches(&self, state: ShortcutsState) -> bool { - self.condition.matches(state) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DisplayCondition { - Always, - WhenShiftEnterHint, - WhenNotShiftEnterHint, - WhenUnderWSL, - WhenCollaborationModesEnabled, -} - -impl DisplayCondition { - fn matches(self, state: ShortcutsState) -> bool { - match self { - DisplayCondition::Always => true, - DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, - DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, - DisplayCondition::WhenUnderWSL => state.is_wsl, - DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, - } - } -} - -struct ShortcutDescriptor { - id: ShortcutId, - bindings: &'static [ShortcutBinding], - prefix: &'static str, - label: &'static str, -} - -impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { - self.bindings.iter().find(|binding| binding.matches(state)) - } - - fn overlay_entry(&self, state: ShortcutsState) -> Option> { - let key = match self.id { - ShortcutId::InsertNewline => state.key_hints.insert_newline, - ShortcutId::QueueMessageTab => state.key_hints.queue, - ShortcutId::ExternalEditor => state.key_hints.external_editor, - ShortcutId::EditPrevious => state.key_hints.edit_previous, - ShortcutId::ShowTranscript => state.key_hints.show_transcript, - ShortcutId::HistorySearch => state.key_hints.history_search, - ShortcutId::ReasoningDown => state.key_hints.reasoning_down, - ShortcutId::ReasoningUp => state.key_hints.reasoning_up, - ShortcutId::Commands - | ShortcutId::ShellCommands - | ShortcutId::FilePaths - | ShortcutId::PasteImage - | ShortcutId::Quit - | ShortcutId::ChangeMode => self - .binding_for(state) - .map(|binding| ShortcutHint::Single(binding.key)), - }?; - let reference_key = key - .display_label() - .fg(crate::style::accent_color_on(/*background*/ None)) - .not_bold() - .not_dim(); - let mut line = Line::from(vec![self.prefix.into(), reference_key.clone()]); - match self.id { - ShortcutId::QueueMessageTab => { - if state.is_task_running || state.queue_submissions { - line.push_span(" to queue message"); - } else { - line.push_span(" to submit message"); - } - } - ShortcutId::EditPrevious => { - if state.esc_backtrack_hint { - line.push_span(" again to edit previous message"); - } else { - line.extend(vec![ - " ".into(), - reference_key, - " to edit previous message".into(), - ]); - } - } - ShortcutId::Quit => { - if state.is_task_running { - line.push_span(" to interrupt"); - } else { - line.push_span(" to exit"); - } - } - _ => line.push_span(self.label), - }; - Some(line) - } -} - -const SHORTCUTS: &[ShortcutDescriptor] = &[ - ShortcutDescriptor { - id: ShortcutId::Commands, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('/')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for commands", - }, - ShortcutDescriptor { - id: ShortcutId::ShellCommands, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('!')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for shell commands", - }, - ShortcutDescriptor { - id: ShortcutId::InsertNewline, - bindings: &[ - ShortcutBinding { - key: key_hint::shift(KeyCode::Enter), - condition: DisplayCondition::WhenShiftEnterHint, - }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('j')), - condition: DisplayCondition::WhenNotShiftEnterHint, - }, - ], - prefix: "", - label: " for newline", - }, - ShortcutDescriptor { - id: ShortcutId::QueueMessageTab, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Tab), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to queue message", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('@')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, - ShortcutDescriptor { - id: ShortcutId::PasteImage, - // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain - // Ctrl+V); otherwise fall back to Ctrl+V. - bindings: &[ - ShortcutBinding { - key: key_hint::ctrl_alt(KeyCode::Char('v')), - condition: DisplayCondition::WhenUnderWSL, - }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('v')), - condition: DisplayCondition::Always, - }, - ], - prefix: "", - label: " to paste images", - }, - ShortcutDescriptor { - id: ShortcutId::ExternalEditor, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('g')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to edit in external editor", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Esc), - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, - ShortcutDescriptor { - id: ShortcutId::HistorySearch, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('r')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " search history", - }, - ShortcutDescriptor { - id: ShortcutId::Quit, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('c')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to exit", - }, - ShortcutDescriptor { - id: ShortcutId::ShowTranscript, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('t')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to view transcript", - }, - ShortcutDescriptor { - id: ShortcutId::ChangeMode, - bindings: &[ShortcutBinding { - key: key_hint::shift(KeyCode::Tab), - condition: DisplayCondition::WhenCollaborationModesEnabled, - }], - prefix: "", - label: " to change mode", - }, - ShortcutDescriptor { - id: ShortcutId::ReasoningDown, - bindings: &[ShortcutBinding { - key: key_hint::alt(KeyCode::Char(',')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " reasoning down", - }, - ShortcutDescriptor { - id: ShortcutId::ReasoningUp, - bindings: &[ShortcutBinding { - key: key_hint::alt(KeyCode::Char('.')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " reasoning up", - }, -]; - -#[cfg(test)] -#[path = "footer_typography_tests.rs"] -mod typography_tests; - #[cfg(test)] mod tests { use super::*; @@ -1626,7 +1227,7 @@ mod tests { collaboration_mode_indicator: Option, context_line: Line<'static>, ) { - let height = footer_height(props).max(1); + let height = footer_height(props, width).max(/*other*/ 1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); draw_footer_frame( &mut terminal, @@ -1645,7 +1246,7 @@ mod tests { collaboration_mode_indicator: Option, context_line: Line<'static>, ) -> String { - let height = footer_height(props).max(1); + let height = footer_height(props, width).max(/*other*/ 1); let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); draw_footer_frame( &mut terminal, @@ -1665,7 +1266,7 @@ mod tests { collaboration_mode_indicator: Option, ide_context_active: bool, ) { - let height = footer_height(props).max(1); + let height = footer_height(props, width).max(/*other*/ 1); let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); draw_footer_frame( &mut terminal, @@ -1685,7 +1286,6 @@ mod tests { FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1703,7 +1303,6 @@ mod tests { FooterProps { mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: true, - use_shift_enter_hint: true, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1724,7 +1323,6 @@ mod tests { FooterProps { mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, @@ -1742,7 +1340,6 @@ mod tests { FooterProps { mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, @@ -1760,7 +1357,6 @@ mod tests { FooterProps { mode: FooterMode::QuitShortcutReminder, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1778,7 +1374,6 @@ mod tests { FooterProps { mode: FooterMode::QuitShortcutReminder, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, @@ -1796,7 +1391,6 @@ mod tests { FooterProps { mode: FooterMode::EscHint, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1814,7 +1408,6 @@ mod tests { FooterProps { mode: FooterMode::EscHint, esc_backtrack_hint: true, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1832,7 +1425,6 @@ mod tests { FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, @@ -1852,7 +1444,6 @@ mod tests { FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1872,7 +1463,6 @@ mod tests { FooterProps { mode: FooterMode::ComposerHasDraft, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, @@ -1888,7 +1478,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, @@ -1917,7 +1506,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: true, @@ -1939,7 +1527,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1956,7 +1543,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerHasDraft, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: true, queue_submissions: false, collaboration_modes_enabled: false, @@ -1973,7 +1559,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerHasDraft, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -1990,7 +1575,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, @@ -2021,7 +1605,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, @@ -2044,7 +1627,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -2068,7 +1650,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, @@ -2093,7 +1674,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -2110,7 +1690,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: false, @@ -2130,7 +1709,6 @@ mod tests { let props = FooterProps { mode: FooterMode::ComposerEmpty, esc_backtrack_hint: false, - use_shift_enter_hint: false, is_task_running: false, queue_submissions: false, collaboration_modes_enabled: true, @@ -2165,44 +1743,4 @@ mod tests { "status line should be truncated with ellipsis to keep mode indicator" ); } - - #[test] - fn paste_image_shortcut_prefers_ctrl_alt_v_under_wsl() { - let descriptor = SHORTCUTS - .iter() - .find(|descriptor| descriptor.id == ShortcutId::PasteImage) - .expect("paste image shortcut"); - - let is_wsl = { - #[cfg(target_os = "linux")] - { - crate::clipboard_paste::is_probably_wsl() - } - #[cfg(not(target_os = "linux"))] - { - false - } - }; - - let expected_key = if is_wsl { - key_hint::ctrl_alt(KeyCode::Char('v')) - } else { - key_hint::ctrl(KeyCode::Char('v')) - }; - - let actual_key = descriptor - .binding_for(ShortcutsState { - use_shift_enter_hint: false, - esc_backtrack_hint: false, - is_task_running: false, - queue_submissions: false, - is_wsl, - collaboration_modes_enabled: false, - key_hints: FooterKeyHints::default_bindings(), - }) - .expect("shortcut binding") - .key; - - assert_eq!(actual_key, expected_key); - } } diff --git a/codex-rs/tui/src/bottom_pane/footer_typography_tests.rs b/codex-rs/tui/src/bottom_pane/footer_typography_tests.rs deleted file mode 100644 index 576c673e94..0000000000 --- a/codex-rs/tui/src/bottom_pane/footer_typography_tests.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Legacy help keeps accent keys while ordinary footer keys clear inherited emphasis. - -use super::*; -use pretty_assertions::assert_eq; -use ratatui::Terminal; -use ratatui::backend::TestBackend; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; - -#[test] -fn help_reference_and_footer_preserve_distinct_key_styles() { - crate::terminal_palette::with_test_default_colors( - crate::terminal_probe::DefaultColors { - fg: (32, 32, 32), - bg: (255, 255, 255), - }, - || { - let help = shortcut_overlay_lines(ShortcutsState { - use_shift_enter_hint: false, - esc_backtrack_hint: false, - is_task_running: false, - queue_submissions: false, - is_wsl: false, - collaboration_modes_enabled: false, - key_hints: FooterKeyHints::default_bindings(), - }); - let help_height = help.len() as u16; - let footer = footer_hint_items_line(&[ - ("ctrl+o".into(), "then".into()), - ("o".into(), "copy".into()), - ]); - let mut terminal = Terminal::new(TestBackend::new( - /*width*/ 80, - /*height*/ help_height + 1, - )) - .unwrap(); - terminal - .draw(|frame| { - let inherited = Style::default().fg(Color::Green).bold().dim(); - frame.render_widget( - Paragraph::new(help).style(inherited), - Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 80, help_height), - ); - frame.render_widget( - Paragraph::new(footer).style(inherited), - Rect::new( - /*x*/ 0, - help_height, - /*width*/ 80, - /*height*/ 1, - ), - ); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - let styles = [ - (0, 0), - (2, 0), - (0, help_height), - (4, help_height), - (7, help_height), - ] - .map(|position| { - let cell = &buffer[position]; - (cell.fg, cell.modifier) - }); - let key_color = key_hint::ctrl(KeyCode::Char('o')).spans()[0] - .style - .fg - .unwrap(); - let secondary_color = secondary_text_style().fg.unwrap(); - assert_eq!( - styles, - [ - ( - crate::style::accent_color_on(/*background*/ None), - Modifier::empty() - ), - (secondary_color, Modifier::empty()), - (key_color, Modifier::BOLD), - (key_color, Modifier::BOLD), - (secondary_color, Modifier::empty()), - ] - ); - }, - ); -} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7b48b26a85..97eb6514de 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -15,6 +15,11 @@ //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. //! Inline banners sit above the composer. Number shortcuts apply only to an empty, idle composer; //! drafts, paste bursts, and active dialogs keep their normal input routing. +pub(crate) use chat_composer::CommandPopupPlacement; +pub(crate) use chat_composer::ComposerRenderOptions; +pub(crate) use chat_composer::TranscriptFooter; +pub(crate) use footer::footer_hint_items_line; +pub(crate) use footer::inset_footer_hint_area; use std::collections::VecDeque; use std::path::PathBuf; @@ -39,8 +44,6 @@ 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; @@ -134,7 +137,14 @@ mod footer; mod list_selection_view; mod memories_settings_view; mod mentions_v2; +mod picker_presets; +mod picker_rows; +mod picker_style; +mod shortcut_overlay; +pub(crate) use picker_style::active_tab_style; +pub(crate) use picker_style::selection_style; pub(crate) mod prompt_args; +mod selection_picker_layout; mod skill_popup; mod skills_toggle_view; pub(crate) mod slash_commands; @@ -164,9 +174,6 @@ 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::footer_hint_items_line; -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; @@ -181,15 +188,10 @@ mod paste_burst; mod pending_input_preview; mod pending_thread_approvals; mod picker_option; -mod picker_presets; -mod picker_rows; pub(crate) use picker_option::picker_option_list; pub(crate) use picker_option::picker_option_row; -mod picker_style; -pub(crate) use picker_style::active_tab_style; pub(crate) mod popup_consts; mod scroll_state; -mod selection_picker_layout; mod selection_popup_common; pub(crate) use selection_popup_common::menu_surface_padding_height; pub(crate) use selection_popup_common::render_menu_surface; @@ -1111,11 +1113,6 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { - self.composer.show_footer_flash(line, duration); - self.request_redraw(); - } - pub(crate) fn set_voice_strip(&mut self, state: Option) { self.composer .set_voice_strip(state, self.frame_requester.clone()); @@ -1627,6 +1624,8 @@ impl BottomPane { .is_some_and(|(name, _, _)| matches!(name, "agents" | "subagents")); self.keymap.chat.interrupt_turn.is_pressed(key_event) + && !(self.shortcut_overlay_visible() + && key_hint::plain(KeyCode::Esc).is_press(key_event)) && self.is_task_running && !(is_agent_command && key_event.code == KeyCode::Esc) && self.no_modal_or_popup_active() @@ -1663,20 +1662,18 @@ impl BottomPane { /// use Esc-Esc for backtracking from the main view. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { !self.is_task_running - && !self.questions.as_ref().is_some_and(|q| q.expanded) - && self.view_stack.is_empty() - && !self.composer.popup_active() + && !self.shortcut_overlay_visible() + && self.can_launch_external_editor() && !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() + !self.has_active_view() && !self.composer.popup_active() && !self.questions.as_ref().is_some_and(|q| q.expanded) } @@ -1994,7 +1991,7 @@ impl BottomPane { } pub(crate) fn attach_image(&mut self, path: PathBuf) { - if self.view_stack.is_empty() { + if !self.has_active_view() { self.composer.attach_image(path); self.request_redraw(); } @@ -2020,22 +2017,12 @@ impl BottomPane { } fn as_renderable(&'_ self) -> RenderableItem<'_> { - self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) - } - - pub(crate) fn as_renderable_with_composer_right_reserve( - &'_ self, - composer_right_reserve: u16, - ) -> RenderableItem<'_> { - self.as_renderable_with_options(ComposerRenderOptions { - textarea_right_reserve: composer_right_reserve, - ..ComposerRenderOptions::default() - }) + self.as_renderable_with_options(ComposerRenderOptions::default()) } pub(crate) fn as_renderable_with_options<'a>( &'a self, - options: ComposerRenderOptions<'a>, + mut options: ComposerRenderOptions<'a>, ) -> RenderableItem<'a> { if (self.is_task_running || !self.view_stack.is_empty()) && let Some(banner) = &self.inline_banner @@ -2120,7 +2107,17 @@ impl BottomPane { { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); } - if let Some(summary) = self.question_summary(Instant::now()) { + let question_summary = self.question_summary(Instant::now()); + // History overlays must not clear the status and previews above this composer. + if options.command_popup_placement == CommandPopupPlacement::Overlay + && (has_status_or_footer + || has_inline_previews + || self.inline_banner.is_some() + || question_summary.is_some()) + { + options.command_popup_placement = CommandPopupPlacement::AboveComposer; + } + if let Some(summary) = question_summary { flex.push( /*flex*/ 0, RenderableItem::Owned(Box::new(Paragraph::new(summary))), @@ -2130,12 +2127,16 @@ 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 options.textarea_right_reserve == 0 && options.footer.is_none() { + } else if options.textarea_right_reserve == 0 + && options.footer.is_none() + && !options.separate_status_line + && options.command_popup_placement == CommandPopupPlacement::AboveComposer + { RenderableItem::Borrowed(&self.composer) } else { - RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable { + RenderableItem::Owned(Box::new(ChatComposerPresentation { composer: &self.composer, - options, + options: self.composer.resolve_render_options(options), })) }; flex2.push(/*flex*/ 0, composer); @@ -2143,6 +2144,11 @@ 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(crate) fn set_status_line(&mut self, status_line: Option>) { if self.composer.set_status_line(status_line) { self.request_redraw(); @@ -2184,12 +2190,12 @@ impl BottomPane { } } -struct ChatComposerRightReserveRenderable<'a> { +struct ChatComposerPresentation<'a> { composer: &'a chat_composer::ChatComposer, options: ComposerRenderOptions<'a>, } -impl Renderable for ChatComposerRightReserveRenderable<'_> { +impl Renderable for ChatComposerPresentation<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { self.composer .render_with_options(area, buf, /*mask_char*/ None, self.options); @@ -2205,7 +2211,15 @@ impl Renderable for ChatComposerRightReserveRenderable<'_> { } fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { - self.composer.cursor_style(area) + if self + .options + .footer + .is_some_and(|footer| footer.is_interactive) + { + crossterm::cursor::SetCursorStyle::SteadyBar + } else { + self.composer.cursor_style(area) + } } } @@ -2389,6 +2403,35 @@ mod tests { assert!(pane.is_normal_backtrack_mode()); } + #[test] + fn escape_closes_shortcut_help_before_inline_banner() { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(ActionableBanner { + title: "Continue working".into(), + description: "Keep this notice while dismissing shortcut help".into(), + ..Default::default() + })); + let width = 80; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + let _ = render_snapshot(&pane, area); + assert_eq!(pane.inline_banner_lifecycle(), (true, false)); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert!(pane.shortcut_overlay_visible()); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!pane.shortcut_overlay_visible()); + assert_eq!(pane.inline_banner_lifecycle(), (true, false)); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(pane.inline_banner_lifecycle(), (true, true)); + } + #[test] fn backend_banner_snapshots_and_numbered_actions() { for (kind, action, label) in [ @@ -3308,6 +3351,27 @@ mod tests { "expected command popup after typing `/rev`" ); + // Owned transcript mode must reserve the popup's rows while task status is visible. + let overlay = pane.as_renderable_with_options(ComposerRenderOptions { + command_popup_placement: CommandPopupPlacement::Overlay, + ..Default::default() + }); + let inline = pane.as_renderable(); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 60, + inline.desired_height(/*width*/ 60), + ); + let mut expected = Buffer::empty(area); + let mut actual = Buffer::empty(area); + inline.render(area, &mut expected); + overlay.render(area, &mut actual); + assert_eq!(overlay.desired_height(/*width*/ 60), area.height); + assert_eq!(actual, expected); + assert_eq!(overlay.cursor_pos(area), inline.cursor_pos(area)); + drop((overlay, inline)); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); while let Ok(ev) = rx.try_recv() { diff --git a/codex-rs/tui/src/bottom_pane/shortcut_overlay.rs b/codex-rs/tui/src/bottom_pane/shortcut_overlay.rs new file mode 100644 index 0000000000..14c114b685 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/shortcut_overlay.rs @@ -0,0 +1,257 @@ +//! A compact shortcut reference with shared width-aware measurement and rendering. +//! +//! Groups flow into three, two, or one column without changing key routing. Runtime hints +//! remain authoritative for remapped, chorded, and disabled bindings. A clipped reference +//! keeps customization visible; the composer reserves a separate bottom row for the close hint. + +use super::footer::FooterProps; +use crate::key_hint; +use crate::key_hint::ShortcutHint; +use crate::style::accent_color; +use crate::style::secondary_text_style; +use crate::wrapping::word_wrap_lines; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +const COLUMN_GAP: usize = 4; + +struct Shortcut { + key: String, + action: &'static str, +} + +impl Shortcut { + fn new(key: impl Into, action: &'static str) -> Self { + Self { + key: key.into().display_label(), + action, + } + } +} + +struct Group { + title: &'static str, + entries: Vec, +} + +impl Group { + fn push(&mut self, key: Option, action: &'static str) { + if let Some(key) = key { + self.entries.push(Shortcut::new(key, action)); + } + } + + fn lines(&self) -> Vec> { + let key_width = self + .entries + .iter() + .map(|entry| Span::raw(&entry.key).width()) + .max() + .unwrap_or(/*default*/ 0); + let mut lines = vec![self.title.bold().into()]; + for entry in &self.entries { + let key = entry.key.clone().fg(accent_color()); + let padding = " ".repeat(key_width.saturating_sub(key.width()) + 2); + lines.push(vec![key, padding.into(), entry.action.into()].into()); + } + lines + } +} + +pub(super) fn lines(props: &FooterProps, width: u16) -> Vec> { + let hints = props.key_hints; + let mut compose = Group { + title: "Compose", + entries: vec![ + Shortcut::new(key_hint::plain(KeyCode::Char('/')), "Commands"), + Shortcut::new(key_hint::plain(KeyCode::Char('@')), "Mention files"), + Shortcut::new(key_hint::plain(KeyCode::Char('!')), "Shell command"), + ], + }; + compose.push(hints.insert_newline, "New line"); + compose.entries.push(Shortcut::new( + if props.is_wsl { + key_hint::ctrl_alt(KeyCode::Char('v')) + } else { + key_hint::ctrl(KeyCode::Char('v')) + }, + "Paste image", + )); + compose.push(hints.external_editor, "External editor"); + compose.push(hints.history_search, "Search history"); + if let Some(key) = hints.edit_previous { + let label = key.display_label(); + compose.entries.push(Shortcut { + key: if props.esc_backtrack_hint { + label + } else { + format!("{label} {label}") + }, + action: "Edit last message", + }); + } + + let mut session = Group { + title: "Session", + entries: Vec::new(), + }; + session.push( + hints.queue, + if props.is_task_running || props.queue_submissions { + "Queue message" + } else { + "Send message" + }, + ); + if props.collaboration_modes_enabled { + session + .entries + .push(Shortcut::new(key_hint::shift(KeyCode::Tab), "Change mode")); + } + session.push(hints.reasoning_down, "Less reasoning"); + session.push(hints.reasoning_up, "More reasoning"); + session.push(hints.agents, "Agents (empty prompt)"); + session.entries.push(Shortcut::new( + key_hint::ctrl(KeyCode::Char('c')), + if props.is_task_running { + "Interrupt" + } else { + "Quit" + }, + )); + + let mut transcript = Group { + title: "Transcript (open first)", + entries: Vec::new(), + }; + transcript.push(hints.show_transcript, "Open transcript"); + transcript.push(hints.find_transcript, "Find text"); + transcript.entries.extend([ + Shortcut { + key: "pgup / pgdn".into(), + action: "Scroll", + }, + Shortcut::new(key_hint::ctrl(KeyCode::Char(' ')), "Start selection"), + Shortcut { + key: "ctrl+home / ctrl+end".into(), + action: "Top / latest", + }, + // Keep both jump alternatives: the terminal may be on another OS over SSH. + Shortcut { + key: format!( + "{} / {}", + key_hint::KeyBinding::new( + KeyCode::Char(','), + KeyModifiers::ALT | KeyModifiers::SHIFT + ) + .display_label(), + key_hint::KeyBinding::new( + KeyCode::Char('.'), + KeyModifiers::ALT | KeyModifiers::SHIFT + ) + .display_label() + ), + action: "Top / latest", + }, + ]); + + let groups = [compose.lines(), session.lines(), transcript.lines()]; + let widths: Vec = groups + .iter() + .map(|group| group.iter().map(Line::width).max().unwrap_or(/*default*/ 0)) + .collect(); + let width = usize::from(width.max(/*other*/ 1)); + let mut result = vec![Line::from("Keyboard shortcuts").bold(), Line::default()]; + if widths.iter().sum::() + COLUMN_GAP * 2 <= width { + result.extend(columns(&groups, &widths)); + } else if widths[0] + COLUMN_GAP + widths[1].max(widths[2]) <= width { + let mut right = groups[1].clone(); + right.push(Line::default()); + right.extend(groups[2].clone()); + result.extend(columns( + &[groups[0].clone(), right], + &[widths[0], widths[1].max(widths[2])], + )); + } else { + for (index, group) in groups.into_iter().enumerate() { + if index > 0 { + result.push(Line::default()); + } + result.extend(group); + } + } + result.push(Line::default()); + result.extend(footer_lines(width)); + word_wrap_lines(&result, width) +} + +pub(super) fn close_hint(props: &FooterProps, width: u16) -> Line<'static> { + let mut line = Line::default(); + if let Some(key) = props.key_hints.toggle_shortcuts { + line.extend(key.spans()); + line.push_span(" / ".set_style(secondary_text_style())); + } + line.extend(key_hint::plain(KeyCode::Esc).spans()); + line.push_span(" close".set_style(secondary_text_style())); + if line.width() > usize::from(width) { + line = Line::from(key_hint::plain(KeyCode::Esc).spans()); + line.push_span(" close".set_style(secondary_text_style())); + } + line +} + +fn footer_lines(width: usize) -> Vec> { + word_wrap_lines( + [Line::from(vec![ + "/keymap".fg(accent_color()), + " customize".set_style(secondary_text_style()), + ])], + width, + ) +} + +fn columns(groups: &[Vec>], widths: &[usize]) -> Vec> { + let height = groups.iter().map(Vec::len).max().unwrap_or(/*default*/ 0); + (0..height) + .map(|row| { + let mut line = Line::default(); + for (column, group) in groups.iter().enumerate() { + let entry = group.get(row).cloned().unwrap_or_default(); + let padding = widths[column].saturating_sub(entry.width()) + COLUMN_GAP; + line.extend(entry.spans); + if column + 1 < groups.len() { + line.push_span(" ".repeat(padding)); + } + } + line + }) + .collect() +} + +pub(super) fn render(props: &FooterProps, area: Rect, buf: &mut Buffer) { + let mut lines = lines(props, area.width); + let height = usize::from(area.height); + if lines.len() > height && height > 0 { + let mut footer = footer_lines(usize::from(area.width.max(/*other*/ 1))); + footer.truncate(height); + let body_height = height.saturating_sub(footer.len()); + lines.truncate(body_height.saturating_sub(/*rhs*/ 1)); + if body_height > 0 { + lines.push(Line::from("… resize to see all").dim()); + } + lines.extend(footer); + } + Paragraph::new(lines).render(area, buf); +} + +#[cfg(test)] +#[path = "shortcut_overlay_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/shortcut_overlay_tests.rs b/codex-rs/tui/src/bottom_pane/shortcut_overlay_tests.rs new file mode 100644 index 0000000000..fe6ebef45a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/shortcut_overlay_tests.rs @@ -0,0 +1,103 @@ +use super::lines; +use crate::bottom_pane::footer::FooterKeyHints; +use crate::bottom_pane::footer::FooterMode; +use crate::bottom_pane::footer::FooterProps; +use crate::bottom_pane::footer::footer_height; +use crate::bottom_pane::footer::render_footer_from_props; +use crate::key_hint; +use crate::key_hint::ShortcutHint; +use crate::ui_consts::FOOTER_INDENT_COLS; +use crossterm::event::KeyCode; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +fn props() -> FooterProps { + FooterProps { + mode: FooterMode::ShortcutOverlay, + esc_backtrack_hint: false, + is_task_running: true, + queue_submissions: false, + collaboration_modes_enabled: true, + is_wsl: false, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + status_line_value: None, + status_line_enabled: false, + key_hints: FooterKeyHints { + agents: Some(key_hint::plain(KeyCode::Left).into()), + ..FooterKeyHints::default_bindings() + }, + active_agent_label: None, + } +} + +fn snapshot(name: &str, width: u16, height: Option, props: &FooterProps) { + let inner_width = width - FOOTER_INDENT_COLS as u16; + let measured_lines = lines(props, inner_width); + assert!( + measured_lines + .iter() + .all(|line| line.width() <= usize::from(inner_width)) + ); + let measured_height = footer_height(props, width); + assert_eq!(usize::from(measured_height), measured_lines.len()); + let height = height.unwrap_or(measured_height); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render_footer_from_props( + frame.area(), + frame.buffer_mut(), + props, + /*collaboration_mode_indicator*/ None, + /*show_cycle_hint*/ false, + /*show_shortcuts_hint*/ false, + /*show_queue_hint*/ false, + ); + }) + .unwrap(); + let painted_height = terminal + .backend() + .buffer() + .content + .chunks(usize::from(width)) + .rposition(|row| row.iter().any(|cell| cell.symbol() != " ")) + .map_or(/*default*/ 0, |row| row + 1); + assert_eq!(painted_height, usize::from(height)); + insta::assert_snapshot!(name, terminal.backend()); +} + +#[test] +fn shortcut_overlay_uses_runtime_bindings_and_wsl_paste() { + let mut props = props(); + props.is_task_running = false; + props.is_wsl = true; + props.esc_backtrack_hint = true; + props.key_hints.external_editor = Some(ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::ctrl(KeyCode::Char('e')), + }); + props.key_hints.toggle_shortcuts = Some(ShortcutHint::Chord { + prefix: key_hint::ctrl(KeyCode::Char('x')), + completion: key_hint::plain(KeyCode::Char('h')), + }); + props.key_hints.insert_newline = Some(key_hint::shift(KeyCode::Enter).into()); + props.key_hints.history_search = None; + props.key_hints.reasoning_down = None; + snapshot( + "shortcut_overlay_customized_wsl", + /*width*/ 80, + /*height*/ None, + &props, + ); +} + +#[test] +fn shortcut_overlay_clipping_preserves_customization_in_small_areas() { + snapshot( + "shortcut_overlay_customize_only", + /*width*/ 28, + /*height*/ Some(1), + &props(), + ); +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap index 67921ef4d3..23735ab827 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap @@ -1,7 +1,26 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5294 expression: terminal.backend() --- +" Keyboard shortcuts " +" " +" Compose Session " +" / Commands tab Send message " +" @ Mention files ⌥+, Less reasoning " +" ! Shell command ⌥+. More reasoning " +" ctrl+j New line ← Agents (empty prompt) " +" ctrl+v Paste image ctrl+c Quit " +" ctrl+g External editor " +" ctrl+r Search history Transcript (open first) " +" esc esc Edit last message ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥+, / shift+⌥+. Top / latest " +" " +" /keymap customize " " " "› Ask Codex to do anything " " " @@ -9,14 +28,4 @@ expression: terminal.backend() " " " " " " -" " -" / for commands ! for shell commands " -" ctrl+j for newline tab to submit message " -" @ for file paths ctrl+v to paste images " -" ctrl+g to edit in external editor esc esc to edit previous message " -" ctrl+r search history ctrl+c to exit " -" ⌥+, reasoning down ⌥+. reasoning up " -" ctrl+t to view transcript " -" ← for agents (empty prompt) " -" " -" customize shortcuts with /keymap " +" ? / esc close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap index fba1b5e6a7..f4dd1d016b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap @@ -1,14 +1,14 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 5348 +assertion_line: 5328 expression: terminal.backend() --- -" " -"› @sa " -" " " Mentions " -" Files, directories, plugins, skills and tasks " " All Results Filesystem Only Plugins " " sa " "› Sample Plugin Plugin with skills and an MCP server Plugin" " enter/tab insert · esc close · ↑/↓ select · ←/→ filter " +" " +"› @sa " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap index 15e4b2654c..ff0bf92a99 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " +" loading... " +" " +" " "› @src $_ " " " " " " " -" " -" " -" loading... " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap index 5264bab854..304cacd14e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " +" loading... " +" " +" " "› @src $1_suffix " " " " " " " -" " -" " -" loading... " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap index c4336faea5..b1b4b812d9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " +" loading... " +" " +" " "› @src $1plugin:d " " " " " " " -" " -" " -" loading... " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap index c3b2201056..16b106d609 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap @@ -1,7 +1,25 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5620 expression: terminal.backend() --- +" Keyboard shortcuts " +" " +" Compose Session " +" / Commands tab Send message " +" @ Mention files ⌥+, Less reasoning " +" ! Shell command ⌥+. More reasoning " +" ctrl+x enter New line ctrl+c Quit " +" ctrl+v Paste image " +" ctrl+g ctrl+g External editor Transcript (open first) " +" ctrl+r Search history ctrl+t Open transcript " +" esc esc Edit last message f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥+, / shift+⌥+. Top / latest " +" " +" /keymap customize " " " "› Ask Codex to do anything " " " @@ -9,13 +27,4 @@ expression: terminal.backend() " " " " " " -" " -" / for commands ! for shell commands " -" ctrl+x enter for newline tab to submit message " -" @ for file paths ctrl+v to paste images " -" ctrl+g ctrl+g to edit in external editor esc esc to edit previous message " -" ctrl+r search history ctrl+c to exit " -" ⌥+, reasoning down ⌥+. reasoning up " -" ctrl+t to view transcript " -" " -" customize shortcuts with /keymap " +" ? / esc close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap index 9cad17b864..66bb11a818 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5609 expression: terminal.backend() --- " " @@ -10,4 +11,4 @@ expression: terminal.backend() " " " " " " -" esc esc to edit previous message " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index a38f043b78..4ae2e3f440 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,7 +1,21 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5294 expression: terminal.backend() --- +" Keyboard shortcuts " +" " +" Compose Session Transcript (open first) " +" / Commands tab Send message ctrl+t Open transcript" +" @ Mention files ⌥+, Less reasoning f3 Find text " +" ! Shell command ⌥+. More reasoning pgup / pgdn Scroll " +" shift+enter New line ctrl+c Quit ctrl+space Start selection" +" ctrl+v Paste image ctrl+home / ctrl+end Top / latest " +" ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+r Search history " +" esc Edit last message " +" " +" /keymap customize " " " "› Ask Codex to do anything " " " @@ -9,13 +23,4 @@ expression: terminal.backend() " " " " " " -" " -" / for commands ! for shell commands " -" shift+enter for newline tab to submit message " -" @ for file paths ctrl+v to paste images " -" ctrl+g to edit in external editor esc again to edit previous message " -" ctrl+r search history ctrl+c to exit " -" ⌥+, reasoning down ⌥+. reasoning up " -" ctrl+t to view transcript " -" " -" customize shortcuts with /keymap " +" ? / esc close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap index ae76e6f2d5..12ec62a631 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap @@ -1,7 +1,21 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5294 expression: terminal.backend() --- +" Keyboard shortcuts " +" " +" Compose Session Transcript (open first) " +" / Commands tab Queue message ctrl+t Open transcript" +" @ Mention files ⌥+, Less reasoning f3 Find text " +" ! Shell command ⌥+. More reasoning pgup / pgdn Scroll " +" shift+enter New line ctrl+c Quit ctrl+space Start selection" +" ctrl+v Paste image ctrl+home / ctrl+end Top / latest " +" ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+r Search history " +" esc esc Edit last message " +" " +" /keymap customize " " " "› Ask Codex to do anything " " " @@ -9,13 +23,4 @@ expression: terminal.backend() " " " " " " -" " -" / for commands ! for shell commands " -" shift+enter for newline tab to queue message " -" @ for file paths ctrl+v to paste images " -" ctrl+g to edit in external editor esc esc to edit previous message " -" ctrl+r search history ctrl+c to exit " -" ⌥+, reasoning down ⌥+. reasoning up " -" ctrl+t to view transcript " -" " -" customize shortcuts with /keymap " +" ? / esc close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap index cddd31e0b6..b49bafb7ad 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $goog " -" " -" " -" " "› Google Calendar [Skill] Find availability and plan event changes " " Google Calendar [App] Look up events and availability " " " " enter insert · esc close " +" " +"› $goog " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap index 4cca10a117..3dcca4598b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $sa " -" " -" " -" " -" " "› Sample Plugin [Plugin] Plugin that includes the Figma MCP server and Skills for common workflows " " " " enter insert · esc close " +" " +"› $sa " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap index b0d8d1b0e3..474efaeacb 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $1p " -" " -" " -" " -" " "› 1password [Skill] Example skill used in tests. " " " " enter insert · esc close " +" " +"› $1p " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap index d0b91448a1..7b5f8d7603 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $left $bound/path " -" " -" " -" " -" " "› left [Skill] Example skill used in tests. " " " " enter insert · esc close " +" " +"› $left $bound/path " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap index cd5dbc868e..36c40c2c59 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $1x $plugin:d " -" " -" " -" " -" " "› deploy (plugin) [Skill] Example skill used in tests. " " " " enter insert · esc close " +" " +"› $1x $plugin:d " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap index e803ced3fb..5478c78b49 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $unbound-skill $bound-skill continue " -" " -" " -" " -" " "› unbound-skill [Skill] Example skill used in tests. " " " " enter insert · esc close " +" " +"› $unbound-skill $bound-skill continue " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap index c14a9ab0ed..4c49e0ba77 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap @@ -3,11 +3,11 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- " " -"› $bound-skill$unbound-skill " -" " -" " -" " -" " "› unbound-skill [Skill] Example skill used in tests. " " " " enter insert · esc close " +" " +"› $bound-skill$unbound-skill " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap index 733b55acee..c7506ed677 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap @@ -2,8 +2,8 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- +"› /archive archive this session " " " "› /ar " " " -" " -"› /archive archive this session " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap index effe79d682..0e08be8744 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap @@ -2,8 +2,8 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- +"› /btw start a side conversation in an ephemeral fork " " " "› /bt " " " -" " -"› /btw start a side conversation in an ephemeral fork " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 350984ff85..84e909b37a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -2,8 +2,8 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- +"› /model choose what model and reasoning effort to use " " " "› /mo " " " -" " -"› /model choose what model and reasoning effort to use " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap new file mode 100644 index 0000000000..e92755f38e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo_clipped.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /mo " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap index a9e9ffa629..3036092395 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap @@ -2,8 +2,8 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- +"› /pets choose or hide the terminal pet " " " "› /pet " " " -" " -"› /pets choose or hide the terminal pet " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap index 5cfb86704f..338cf04995 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -1,10 +1,11 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 9511 expression: terminal.backend() --- +"› /resume resume a saved chat " " " "› /res " " " " " -" " -"› /resume resume a saved chat " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap index 3ee0b8b231..271a119104 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap @@ -2,8 +2,8 @@ source: tui/src/bottom_pane/chat_composer.rs expression: terminal.backend() --- +"› /side start a side conversation in an ephemeral fork " " " "› /si " " " -" " -"› /side start a side conversation in an ephemeral fork " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap index a0ed196038..b70c05ce40 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap @@ -1,14 +1,14 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 5348 +assertion_line: 5328 expression: terminal.backend() --- -" " -"› @migr " -" " " Mentions " -" Files, directories, plugins, skills and tasks " " All Results Filesystem Only Plugins " " migr " "› Review database migration /workspace/project Task " " enter/tab insert · esc close · ↑/↓ select · ←/→ filter " +" " +"› @migr " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap index a7a63e57b4..5c29d42f7f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap @@ -1,14 +1,14 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 5348 +assertion_line: 5328 expression: terminal.backend() --- -" " -"› @left @bound " -" " " Mentions " -" Files, directories, plugins, skills and tasks " " All Results Filesystem Only Plugins " " left " "› left Plugin used to test bound mention fallback. Plugin" " enter/tab insert · esc close · ↑/↓ select · ←/→ filter " +" " +"› @left @bound " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap index 6b45b58d83..d248b7de58 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap @@ -1,7 +1,9 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5328 expression: terminal.backend() --- +"› /diff show git diff (including untracked files) " " " "› /dide tail " " " @@ -9,5 +11,4 @@ expression: terminal.backend() " " " " " " -" " -"› /diff show git diff (including untracked files) " +" 100% context left | Vim: Replace " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index 3685bb98db..eae1937523 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/command_popup.rs -assertion_line: 523 expression: commands --- /model - choose what model and reasoning effort to use @@ -25,7 +24,7 @@ expression: commands /init - create an AGENTS.md file with instructions for Codex /compact - summarize conversation to prevent hitting the context limit /recap - summarize the current conversation now -/agents - view and switch between all active agent sessions +/agents - open the agent command center /side - start a side conversation in an ephemeral fork /copy - copy the last response or part of it /export - export the conversation as markdown diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap index cdb6a9e673..6a46157528 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -1,13 +1,23 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 1240 expression: terminal.backend() --- -" / for commands ! for shell commands " -" ctrl+j for newline tab to submit message " -" @ for file paths ctrl+v to paste images " -" ctrl+g to edit in external editor esc esc to edit previous message " -" ctrl+r search history ctrl+c to exit " -" ⌥+, reasoning down ⌥+. reasoning up " -" shift+tab to change mode ctrl+t to view transcript " +" Keyboard shortcuts " " " -" customize shortcuts with /keymap " +" Compose Session " +" / Commands tab Send message " +" @ Mention files shift+tab Change mode " +" ! Shell command ⌥+, Less reasoning " +" ctrl+j New line ⌥+. More reasoning " +" ctrl+v Paste image ctrl+c Quit " +" ctrl+g External editor " +" ctrl+r Search history Transcript (open first) " +" esc esc Edit last message ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥+, / shift+⌥+. Top / latest " +" " +" /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap index 63e2858b65..1747fbdb55 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap @@ -1,13 +1,22 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 1240 expression: terminal.backend() --- -" / for commands ! for shell commands " -" ctrl+j for newline tab to queue message " -" @ for file paths ctrl+v to paste images " -" ctrl+g to edit in external editor esc esc to edit previous message " -" ctrl+r search history ctrl+c to interrupt " -" ⌥+, reasoning down ⌥+. reasoning up " -" ctrl+t to view transcript " +" Keyboard shortcuts " " " -" customize shortcuts with /keymap " +" Compose Session " +" / Commands tab Queue message " +" @ Mention files ⌥+, Less reasoning " +" ! Shell command ⌥+. More reasoning " +" ctrl+j New line ctrl+c Interrupt " +" ctrl+v Paste image " +" ctrl+g External editor Transcript (open first) " +" ctrl+r Search history ctrl+t Open transcript " +" esc esc Edit last message f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥+, / shift+⌥+. Top / latest " +" " +" /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 7bf5032eb6..748fefa2fc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,13 +1,22 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 1240 expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift+enter for newline tab to submit message " -" @ for file paths ctrl+v to paste images " -" ctrl+g to edit in external editor esc again to edit previous message " -" ctrl+r search history ctrl+c to exit " -" ⌥+, reasoning down ⌥+. reasoning up " -" ctrl+t to view transcript " +" Keyboard shortcuts " " " -" customize shortcuts with /keymap " +" Compose Session " +" / Commands tab Send message " +" @ Mention files ⌥+, Less reasoning " +" ! Shell command ⌥+. More reasoning " +" shift+enter New line ctrl+c Quit " +" ctrl+v Paste image " +" ctrl+g External editor Transcript (open first) " +" ctrl+r Search history ctrl+t Open transcript " +" esc Edit last message f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥+, / shift+⌥+. Top / latest " +" " +" /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customize_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customize_only.snap new file mode 100644 index 0000000000..dd598c97ca --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customize_only.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/shortcut_overlay_tests.rs +assertion_line: 67 +expression: terminal.backend() +--- +" /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap new file mode 100644 index 0000000000..48b0d80fc3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/shortcut_overlay_tests.rs +expression: terminal.backend() +--- +" Keyboard shortcuts " +" " +" Compose Session " +" / Commands tab Send message " +" @ Mention files shift+tab Change mode " +" ! Shell command ⌥+. More reasoning " +" shift+enter New line ← Agents (empty prompt) " +" ctrl+⌥+v Paste image ctrl+c Quit " +" ctrl+x ctrl+e External editor " +" esc Edit last message Transcript (open first) " +" ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥+, / shift+⌥+. Top / latest " +" " +" /keymap customize " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f42ed73741..ec2fc025dd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -285,7 +285,6 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collaboration_modes; use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; use crate::exec_command::split_command_string; @@ -322,6 +321,7 @@ mod command_lifecycle; mod connector_mentions; mod connectors; mod constructor; +mod dynamic_activity; pub(crate) use self::connectors::ConnectorScopeGeneration; use self::connectors::ConnectorsState; mod exec_state; @@ -359,6 +359,7 @@ mod pets; mod session_flow; mod session_header; use self::session_header::SessionHeader; +mod copy_picker; mod hook_lifecycle; mod hooks; mod interaction; @@ -421,7 +422,6 @@ pub(crate) use realtime::tests::activate_voice_for_thread; pub(crate) use realtime::tests::commit_realtime_history_events; mod reasoning_shortcuts; use self::realtime::RealtimeConversationUiState; -mod copy_picker; mod rendering; mod replay; mod review; @@ -445,7 +445,6 @@ use self::status_surfaces::CachedProjectRootName; mod thread_title_status; mod thread_usage; pub(crate) use self::thread_usage::ThreadUsageOutcome; -mod dynamic_activity; mod tool_lifecycle; mod tool_requests; mod transcript; @@ -835,7 +834,8 @@ enum CodexOpTarget { /// it cheaply decide when to recompute that tail as the active cell evolves. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ActiveCellTranscriptKey { - /// External mutable sources must refresh even when the explicit revision is unchanged. + /// Whether unchanged keys may reuse the live layout across redraws. + /// External state can change a cell without advancing the widget's revision. pub(crate) cacheable: bool, /// Cache-busting revision for in-place updates. /// @@ -1980,13 +1980,12 @@ impl ChatWidget { /// Returns a cache key describing the current in-flight cells for the transcript overlay. /// /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the - /// current active and asynchronous usage cells, and the overlay caches that tail; this + /// current active, realtime, and rate-limit cells, and the overlay caches that tail; this /// key is what it uses to decide whether it must recompute. When there are no live cells, this /// returns `None` so the overlay can drop the tail entirely. /// - /// If callers mutate the active cell's transcript output without bumping the revision (or - /// providing an appropriate animation tick), the overlay will keep showing a stale tail while - /// the main viewport updates. + /// Sources backed by external mutable state disable reuse even when revision and animation + /// tick are unchanged. Stable sources still require a revision bump when their content changes. pub(crate) fn active_cell_transcript_key(&self) -> Option { let cell = self.transcript.active_cell.as_ref(); let mut realtime_cells = self.realtime_conversation.live_transcript_cells(); diff --git a/codex-rs/tui/src/chatwidget/activity_presentation.rs b/codex-rs/tui/src/chatwidget/activity_presentation.rs index fab2bbafcf..55c11ccd6d 100644 --- a/codex-rs/tui/src/chatwidget/activity_presentation.rs +++ b/codex-rs/tui/src/chatwidget/activity_presentation.rs @@ -5,37 +5,6 @@ use super::*; use crate::transcript_view::ActivityTranscriptLines; impl ChatWidget { - pub(super) 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) - } - pub(crate) fn active_activity_ids(&self) -> Vec { self.transcript .active_cell diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index 3dd5370fb5..6374bf0bb6 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -4,6 +4,7 @@ //! exec-cell grouping and unified exec wait state. use super::*; +use crate::exec_cell::CommandOutput; impl ChatWidget { pub(super) fn flush_unified_exec_wait_streak(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 2d06ec98e1..7162c7e1fd 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -3,6 +3,7 @@ //! The queue data itself lives in `input_queue`; this module owns the app-level //! effects around taking composer input, submitting user turns, draining queued //! follow-ups, and restoring draft state across interrupts or thread switches. +//! Composer submissions resume transcript following before dispatch or startup queueing. use super::*; use crate::bottom_pane::prompt_args::parse_slash_name; @@ -21,6 +22,14 @@ impl ChatWidget { input_result: InputResult, had_modal_or_popup: bool, ) { + if matches!( + &input_result, + InputResult::Command(_) + | InputResult::ServiceTierCommand(_) + | InputResult::CommandWithArgs(..) + ) { + self.app_event_tx.send(AppEvent::FollowTranscript); + } match input_result { InputResult::Submitted { text, @@ -33,6 +42,7 @@ impl ChatWidget { { return; } + self.app_event_tx.send(AppEvent::FollowTranscript); let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui() && !self.input_queue.suppress_queue_autosend @@ -64,7 +74,9 @@ impl ChatWidget { pending_pastes, } => { let user_message = self.user_message_from_submission(text, text_elements); - self.queue_user_message_with_options(user_message, action, pending_pastes); + if self.queue_user_message_with_options(user_message, action, pending_pastes) { + self.app_event_tx.send(AppEvent::FollowTranscript); + } } InputResult::Command(cmd) => { self.handle_slash_command_dispatch(cmd); @@ -113,7 +125,7 @@ impl ChatWidget { .set_queue_submissions(queue && !self.is_session_configured()); } - pub(super) fn queue_user_message_with_options( + pub(crate) fn queue_user_message_with_options( &mut self, user_message: UserMessage, action: QueuedInputAction, diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 1f044387a6..205cd05902 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -43,6 +43,11 @@ impl ChatWidget { return; } + if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) { + self.bottom_pane.handle_key_event(key_event); + return; + } + if (self.chat_keymap.interrupt_turn.is_pressed(key_event) || key_hint::ctrl(KeyCode::Char('c')).is_press(key_event)) && self.bottom_pane.no_modal_or_popup_active() @@ -303,7 +308,6 @@ impl ChatWidget { .replace_selection_view_if_present(view_id, params) } - #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(crate) fn shortcut_overlay_visible(&self) -> bool { self.bottom_pane.shortcut_overlay_visible() } @@ -341,19 +345,22 @@ impl ChatWidget { &mut self, copy_fn: impl FnOnce(&str) -> Result, ) { + // The shortcut bypasses composer submission, which normally reveals local feedback. + self.app_event_tx.send(AppEvent::FollowTranscript); match self.transcript.last_agent_markdown.clone() { - Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { - Ok(outcome) => { - let status = outcome.store(&mut self.clipboard_lease); - self.add_to_history(history_cell::new_info_event( - status.message("last message"), - /*hint*/ None, - )); + Some(markdown) if !markdown.is_empty() => { + match self.write_clipboard(&markdown, copy_fn) { + Ok(status) => { + self.add_to_history(history_cell::new_info_event( + status.message("last message"), + /*hint*/ None, + )); + } + Err(error) => self.add_to_history(history_cell::new_error_event(format!( + "Copy failed: {error}" + ))), } - Err(error) => self.add_to_history(history_cell::new_error_event(format!( - "Copy failed: {error}" - ))), - }, + } _ => self.add_to_history(history_cell::new_error_event( "No agent response to copy".into(), )), @@ -373,7 +380,6 @@ impl ChatWidget { } /// The owned viewport renders its own copy feedback above the composer. - #[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")] pub(super) fn copy_transcript_selection_with( &mut self, text: &str, diff --git a/codex-rs/tui/src/chatwidget/plan_implementation.rs b/codex-rs/tui/src/chatwidget/plan_implementation.rs index 4354de95ed..d4a5d4aa18 100644 --- a/codex-rs/tui/src/chatwidget/plan_implementation.rs +++ b/codex-rs/tui/src/chatwidget/plan_implementation.rs @@ -34,6 +34,7 @@ pub(super) fn selection_view_params( Some(mask) => { let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::FollowTranscript); tx.send(AppEvent::SubmitUserMessageWithMode { text: user_text.clone(), collaboration_mode: mask.clone(), diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 9ef8d32107..6e0d48a178 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -1,4 +1,4 @@ -//! Render composition for the main chat widget surface. +//! Shared composer and live-history composition for inline and owned transcript surfaces. use super::transcript::ActiveCellLayoutCache; use super::transcript::ActiveCellLayoutCacheKey; @@ -122,9 +122,10 @@ impl ChatWidget { .selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID) .is_some() { - return self - .bottom_pane - .as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0); + return self.bottom_pane_renderable( + /*footer*/ None, + crate::bottom_pane::CommandPopupPlacement::AboveComposer, + ); } let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); @@ -183,20 +184,27 @@ impl ChatWidget { })), ); } - let bottom = self.bottom_pane_renderable(/*footer*/ None); flex.push( /*flex*/ 0, - bottom.inset(Insets::tlbr( + self.bottom_pane_renderable( + /*footer*/ None, + crate::bottom_pane::CommandPopupPlacement::AboveComposer, + ) + .inset(Insets::tlbr( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )), ); RenderableItem::Owned(Box::new(flex)) } - /// Render only the composer and its local surfaces beneath an owned transcript. + /// Returns the composer, footer, and active modal without the live transcript above it. + /// + /// Both transcript surfaces use this composition so read-only notices and cursor placement + /// remain consistent. The caller owns any spacing between the transcript and this surface. pub(crate) fn bottom_pane_renderable<'a>( &'a self, footer: Option<&'a crate::bottom_pane::TranscriptFooter>, + command_popup_placement: crate::bottom_pane::CommandPopupPlacement, ) -> RenderableItem<'a> { if self.external_writer_view && !self.bottom_pane.has_active_view() { RenderableItem::Owned(Box::new(ExternalWriterNotice { @@ -204,14 +212,70 @@ impl ChatWidget { transcript_hint: self.bottom_pane.transcript_shortcut_hint(), })) } else { + let right_reserve = if self + .bottom_pane + .selected_index_for_active_view(crate::app::AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + 0 + } else { + self.ambient_pet_wrap_reserved_cols() + }; self.bottom_pane .as_renderable_with_options(crate::bottom_pane::ComposerRenderOptions { - textarea_right_reserve: self.ambient_pet_wrap_reserved_cols(), + textarea_right_reserve: right_reserve, + separate_status_line: command_popup_placement + != crate::bottom_pane::CommandPopupPlacement::AboveComposer, + command_popup_placement, footer, }) } } + /// Returns compact live-history lines using the current rich or raw presentation. + #[cfg(test)] + pub(crate) fn active_cell_display_hyperlink_lines( + &self, + width: u16, + ) -> Option> { + self.active_cell_hyperlink_lines_with(width, |cell, width| { + cell.display_hyperlink_lines_for_mode(width, self.history_render_mode()) + }) + } + + /// Combines the same live cells for compact and detailed transcript rendering. + pub(super) fn active_cell_hyperlink_lines_with( + &self, + width: u16, + render_cell: impl Fn(&dyn HistoryCell, u16) -> Vec, + ) -> Option> { + let cells = self + .transcript + .active_cell + .as_deref() + .into_iter() + .chain( + self.realtime_conversation + .pending_history_cells + .iter() + .chain(self.realtime_conversation.live_transcript_cells()) + .map(AsRef::as_ref), + ) + .chain( + self.pending_rate_limit_reset_hint() + .map(|cell| cell as &dyn HistoryCell), + ); + let mut lines = Vec::new(); + for cell in cells { + let cell_lines = render_cell(cell, width); + if !cell_lines.is_empty() && !lines.is_empty() { + lines.push(HyperlinkLine::from("")); + } + lines.extend(cell_lines); + } + (!lines.is_empty()).then_some(lines) + } + pub(crate) fn note_rendered_width(&self, width: u16) { self.last_rendered_width.set(Some(width)); } diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index e0c844396b..e08a103ea4 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -75,22 +75,169 @@ fn contains_text(buffer: &Buffer, text: &str) -> bool { }) } +#[tokio::test] +async fn owned_bottom_pane_preserves_draft_cursor_and_read_only_notice() { + let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; + widget.handle_paste("draft stays here".to_string()); + let area = Rect::new( + /*x*/ 0, /*y*/ 8, /*width*/ 60, /*height*/ 8, + ); + let render_bottom = |widget: &ChatWidget| { + let bottom = widget.bottom_pane_renderable( + /*footer*/ None, + crate::bottom_pane::CommandPopupPlacement::Overlay, + ); + let mut buffer = Buffer::empty(area); + bottom.render(area, &mut buffer); + (buffer, bottom.cursor_pos(area), bottom.cursor_style(area)) + }; + let before = render_bottom(&widget); + assert!(contains_text(&before.0, "draft stays here")); + assert!(before.1.is_some()); + + widget.transcript.active_cell = Some(Box::new(history_cell::PlainHistoryCell::new(vec![ + "live output belongs in the transcript".into(), + ]))); + assert_eq!(render_bottom(&widget), before); + + widget.show_external_writer_thread(); + let (notice, cursor, _) = render_bottom(&widget); + assert!(contains_text( + ¬ice, + "This conversation is open in another app" + )); + assert_eq!(cursor, None); +} + +#[derive(Debug)] +struct PresentationCell(&'static str); + +impl HistoryCell for PresentationCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec![format!("compact {}", self.0).into()] + } + + fn raw_lines(&self) -> Vec> { + vec![format!("raw {}", self.0).into()] + } + + fn transcript_lines(&self, _width: u16) -> Vec> { + vec![format!("detailed {}", self.0).into()] + } +} + +#[tokio::test] +async fn owned_live_history_keeps_all_sources_in_each_presentation() { + let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; + widget.transcript.active_cell = Some(Box::new(PresentationCell("active"))); + widget.realtime_conversation.live_transcript_cell = Some(Box::new(PresentationCell("voice"))); + widget.pending_rate_limit_reset_hint = Some(history_cell::PlainHistoryCell::new(vec![ + "rate limit hint".into(), + ])); + let text = |lines: Option>| { + lines + .unwrap_or_default() + .into_iter() + .map(|line| line.line.to_string()) + .collect::>() + .join("\n") + }; + let compact = text(widget.active_cell_display_hyperlink_lines(/*width*/ 80)); + widget.set_raw_output_mode(/*enabled*/ true); + let raw = text(widget.active_cell_display_hyperlink_lines(/*width*/ 80)); + let detailed = text(widget.active_cell_transcript_hyperlink_lines(/*width*/ 80)); + + insta::assert_snapshot!(format!("{compact}\n---\n{raw}\n---\n{detailed}"), @" + compact active + + compact voice + + rate limit hint + --- + raw active + + raw voice + + rate limit hint + --- + detailed active + + detailed voice + + rate limit hint + "); + + widget.local_settings.tui.animations = false; + widget.realtime_conversation = Default::default(); + crate::chatwidget::realtime::tests::activate_voice_for_thread(&mut widget, ThreadId::new()); + widget.realtime_conversation.pending_history_cells.extend([ + Box::new(PresentationCell("deferred first")) as Box, + Box::new(PresentationCell("deferred second")) as Box, + ]); + widget.on_realtime_transcript_delta("assistant".into(), "assistant ".into()); + widget.on_realtime_transcript_delta("user".into(), "user caption".into()); + widget.on_realtime_transcript_delta("assistant".into(), "caption".into()); + let expected = [ + "active", + "deferred first", + "deferred second", + "user caption", + "assistant caption", + "rate limit hint", + ]; + + for latest_speaker in ["assistant", "user"] { + widget.on_realtime_transcript_delta(latest_speaker.into(), String::new()); + widget.set_raw_output_mode(/*enabled*/ false); + let compact = text(widget.active_cell_display_hyperlink_lines(/*width*/ 80)); + let frame = render_frame(&widget, /*width*/ 80); + let inline = frame + .content + .chunks(usize::from(frame.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + widget.set_raw_output_mode(/*enabled*/ true); + let raw = text(widget.active_cell_display_hyperlink_lines(/*width*/ 80)); + let detailed = text(widget.active_cell_transcript_hyperlink_lines(/*width*/ 80)); + for (presentation, rendered) in [ + ("compact", compact), + ("inline", inline), + ("raw", raw), + ("detailed", detailed), + ] { + let actual = rendered + .lines() + .filter_map(|line| { + expected + .iter() + .find(|source| line.contains(**source)) + .copied() + }) + .collect::>(); + assert_eq!( + actual, expected, + "{presentation}; latest speaker {latest_speaker}" + ); + } + } +} + #[tokio::test] async fn external_writer_view_shows_notice_instead_of_composer() { let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; widget.show_external_writer_thread(); - let (frame, label_style) = crate::terminal_palette::with_test_default_colors( + let frame = crate::terminal_palette::with_test_default_colors( crate::terminal_probe::DefaultColors { fg: (230, 230, 230), bg: (20, 20, 20), }, - || { - ( - render_frame(&widget, /*width*/ 60), - crate::style::footer_hint_label_style(), - ) - }, + || render_frame(&widget, /*width*/ 60), ); let rows: Vec = frame .content @@ -105,14 +252,14 @@ async fn external_writer_view_shows_notice_instead_of_composer() { ); assert_ne!(frame[(0, 1)].bg, ratatui::style::Color::Reset); assert_eq!(frame[(0, 1)].bg, frame[(59, 4)].bg); - assert_eq!(frame[(3, 5)].modifier, ratatui::style::Modifier::BOLD); assert_eq!( - (frame[(5, 5)].fg, frame[(5, 5)].modifier), + (frame[(3, 5)].fg, frame[(3, 5)].modifier), ( - label_style.fg.unwrap_or(ratatui::style::Color::Reset), - ratatui::style::Modifier::empty(), + crate::terminal_palette::rgb_color((230, 230, 230)), + ratatui::style::Modifier::BOLD ), ); + assert_eq!(frame[(5, 5)].modifier, ratatui::style::Modifier::empty()); assert!(!widget.bottom_pane.composer_input_enabled()); } @@ -391,32 +538,3 @@ async fn external_writer_notice_offers_command_center_on_shared_servers() { } } } - -#[tokio::test] -async fn externally_mutable_active_cells_refresh_the_transcript_without_a_revision_change() { - let (widget, _height_calls, display_calls) = widget_with_counting_cell( - /*desired_height*/ 2, /*line_count*/ 2, /*stable_height*/ false, - ) - .await; - let mut overlay = crate::pager_overlay::TranscriptOverlay::new( - Vec::new(), - crate::keymap::RuntimeKeymap::defaults().pager, - ); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 10, - ); - let key = widget.active_cell_transcript_key(); - let mut frames = Vec::new(); - for _ in 0..2 { - assert_eq!(widget.active_cell_transcript_key(), key); - overlay.sync_live_tail(area.width, key, |width| { - widget.active_cell_transcript_hyperlink_lines(width) - }); - let mut buffer = Buffer::empty(area); - overlay.render(area, &mut buffer); - frames.push(buffer); - } - assert_eq!(display_calls.load(Ordering::Relaxed), 2); - assert!(contains_text(&frames[0], "frame 1 row 0")); - assert!(contains_text(&frames[1], "frame 2 row 0")); -} diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index b788313049..e170150768 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -3,6 +3,7 @@ //! checks the previous model when it applies the final selection. use super::*; +use crate::style::accent_color; impl ChatWidget { /// Offer the flourish only for a successful primary thread/start without initial work. @@ -284,9 +285,9 @@ impl ChatWidget { vec![ "• ".dim(), "Thread forked from ".into(), - name.cyan(), + name.fg(accent_color()), " (".into(), - forked_from_id_text.cyan(), + forked_from_id_text.fg(accent_color()), ")".into(), ] .into() @@ -294,7 +295,7 @@ impl ChatWidget { vec![ "• ".dim(), "Thread forked from ".into(), - forked_from_id_text.cyan(), + forked_from_id_text.fg(accent_color()), ] .into() }; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_mentions_only_callable_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_mentions_only_callable_installed.snap index c00c803e34..10998053d0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_mentions_only_callable_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_mentions_only_callable_installed.snap @@ -1,14 +1,16 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 2316 expression: snapshot --- -› $ - - › Google Drive [App] Connected files enter insert · esc close +› $ + + GPT-5.6-Sol default · /tmp/project + --- plugin authentication --- Plugins Plugin plugin installed. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index 38fb05e28d..4208613bc7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -29,7 +29,7 @@ expression: "lines[start_idx..].join(\"\\n\")" • Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy │ file-search linux-sandbox login mcp-client mcp-server ollama │ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; - │ … +1 lines + │ … +1 line └ --- ansi-escape/Cargo.toml [package] … +243 lines diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap deleted file mode 100644 index f973494e98..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_false_40.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/chatwidget/tests/permissions.rs -expression: rendered ---- - Set up the Codex agent sandbox to - protect your files and control - network access. Learn more - - - - 1. Set up default sandbox (requires - Administrator permissions) -› 2. Use non-admin sandbox (higher risk - if prompt injected) - 3. Quit - - f3 select · f2 back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap deleted file mode 100644 index d7c9f951e2..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_picker_fallback_true_40.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tui/src/chatwidget/tests/permissions.rs -expression: rendered ---- - Couldn't set up your sandbox with - Administrator permissions - - You can still use Codex in a - non-admin sandbox. It carries - greater risk if prompt injected. - Learn more - - - - 1. Try setting up admin sandbox again -› 2. Use Codex with non-admin sandbox - 3. Quit - - f3 select · f2 back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap index 40cf9e25f0..d42eb2f2bb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__worktrees_command_local.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests/worktree_picker_tests.rs expression: popup --- +› /worktree start or continue a conversation in a new worktree + › /work -› /worktree start or continue a conversation in a new worktree + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 00a05aa0ce..dd92dbb56b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -249,14 +249,15 @@ mod computer_activity_tests; mod config_errors; #[path = "tests/copy_export_picker_tests.rs"] mod copy_export_picker_tests; +#[path = "tests/dynamic_activity_tests.rs"] mod dynamic_activity_tests; mod exec_flow; mod goal_menu; mod goal_validation; -#[path = "tests/group_projection_tests.rs"] -mod group_projection_tests; mod guardian; pub(crate) mod helpers; +#[path = "tests/history_projection.rs"] +mod history_projection; mod history_replay; #[path = "tests/luna_reserve_usage_tests.rs"] mod luna_reserve_usage_tests; @@ -297,8 +298,6 @@ mod status_surface_previews; mod terminal_title; #[path = "tests/tool_activity_tests.rs"] mod tool_activity_tests; -#[path = "tests/tool_details_tests.rs"] -mod tool_details_tests; mod usage; #[path = "tests/worktree_picker_tests.rs"] mod worktree_picker; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f4d90ede89..2d11e9a075 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1809,7 +1809,7 @@ async fn live_app_server_model_verification_renders_warning() { /*replay_kind*/ None, ); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_transcript(&mut rx); assert_eq!(cells.len(), 1); let rendered = lines_to_single_string(&cells[0]); assert!(rendered.contains("multiple flags for possible cybersecurity risk")); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 98b85d010b..779409a062 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -13,6 +13,34 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::collections::VecDeque; +#[tokio::test] +async fn composer_submission_sends_once_and_requests_latest() { + let (mut chat, mut events, mut operations) = + make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.apply_external_edit("send once".to_string()); + while events.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!( + std::iter::from_fn(|| events.try_recv().ok()) + .filter(|event| matches!(event, AppEvent::FollowTranscript)) + .count(), + 1 + ); + let Op::UserTurn { items, .. } = next_submit_op(&mut operations) else { + panic!("expected submitted user turn"); + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "send once".into(), + text_elements: Vec::new(), + }] + ); + assert!(operations.try_recv().is_err()); + assert_eq!(chat.composer_text_with_pending(), ""); +} + fn paste_hidden_shell_payload(chat: &mut ChatWidget) -> String { let payload = format!("!echo {}", "x".repeat(1000)); chat.handle_paste(payload.clone()); diff --git a/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs b/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs index 93b211e8ef..d528070c66 100644 --- a/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/dynamic_activity_tests.rs @@ -74,8 +74,13 @@ async fn terminal_dynamic_activity_retains_calls_across_both_fallbacks() { } assert_eq!(outputs[0], outputs[1]); insta::assert_snapshot!( - "terminal_dynamic_activity", - lines_to_single_string(&outputs[0]) + lines_to_single_string(&outputs[0]), + @" + • Failed test.lookup · 25ms + └ Result for call-1 + • Called test.lookup · 25ms + └ Result for call-2 + " ); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 7207c87cec..efa06b930a 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1513,6 +1513,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected RunUserShellCommand op, got {other:?}"), } + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_matches!( rx.try_recv(), Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi" diff --git a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs index b842f8beb7..fccf2d11da 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs @@ -58,6 +58,7 @@ async fn goal_slash_command_accepts_objective_at_limit() { let command = format!("/goal {objective}"); submit_composer_text(&mut chat, &command); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); let event = rx.try_recv().expect("expected goal objective event"); let AppEvent::SetThreadGoalDraft { @@ -82,6 +83,7 @@ async fn goal_slash_command_accepts_multiline_objective_after_blank_first_line() let objective = "follow these instructions\npreserve this detail"; submit_composer_text(&mut chat, &format!("/goal \n\n{objective}")); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); let event = rx.try_recv().expect("expected goal objective event"); let AppEvent::SetThreadGoalDraft { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index b528c88aa6..9457f66644 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -364,7 +364,17 @@ pub(super) fn drain_insert_history_normalized( pub(super) fn drain_insert_history_transcript( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> { - drain_insert_history_with(rx, |cell| cell.transcript_lines(/*width*/ 80)) + drain_insert_history_with(rx, |cell| { + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|mut line| { + if cell.as_any().is::() { + line.spans = vec![normalize_completion_timestamps(cell, &line).into()]; + } + line + }) + .collect() + }) } // Preserve ordering checks for history cells intentionally hidden from compact chat. diff --git a/codex-rs/tui/src/chatwidget/tests/group_projection_tests.rs b/codex-rs/tui/src/chatwidget/tests/history_projection.rs similarity index 69% rename from codex-rs/tui/src/chatwidget/tests/group_projection_tests.rs rename to codex-rs/tui/src/chatwidget/tests/history_projection.rs index f4079a26a7..2a63c7b260 100644 --- a/codex-rs/tui/src/chatwidget/tests/group_projection_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_projection.rs @@ -1,4 +1,4 @@ -//! Persisted tool cells must match initial replay in legacy and detailed presentations. +//! Persisted tool cells must match initial replay in compact and detailed presentations. use super::*; use crate::thread_transcript::RawReasoningVisibility; @@ -57,7 +57,7 @@ async fn older_tool_projection_matches_initial_replay() { duration_ms: Some(5), }); } - // Four adjacent CUA calls legacy together; a regular MCP call is a group boundary. + // Four adjacent CUA calls compact together; a regular MCP call is a group boundary. for (server, id) in [ ("cua_repl", "1"), ("cua_repl", "2"), @@ -166,7 +166,7 @@ async fn older_tool_projection_matches_initial_replay() { insta::assert_snapshot!( "completion_only_replay_exploration_group", format!( - "legacy:\n{}\ndetailed:\n{}", + "compact:\n{}\ndetailed:\n{}", lines_to_single_string(&exploration.display_lines(/*width*/ 40)), lines_to_single_string(&exploration.transcript_lines(/*width*/ 40)), ) @@ -174,7 +174,7 @@ async fn older_tool_projection_matches_initial_replay() { } #[tokio::test] -async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() { +async fn snapshot_formatter_reasoning_matches_compact_and_detailed_replay() { let mut snapshots = Vec::new(); for visibility in [ RawReasoningVisibility::Hidden, @@ -214,7 +214,7 @@ async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() { ); } snapshots.push(format!( - "{visibility:?}\nlegacy: {:?}\ndetailed:\n{}", + "{visibility:?}\ncompact: {:?}\ndetailed:\n{}", projected[0].display_lines(/*width*/ 80), lines_to_single_string(&projected[0].transcript_lines(/*width*/ 80)), )); @@ -262,6 +262,117 @@ fn raw_reasoning_keeps_its_own_heading() { "); } +#[tokio::test] +async fn snapshot_formatter_completed_patch_needs_no_started_notification() { + for replay_kind in [ + ReplayKind::ResumeInitialMessages, + ReplayKind::ThreadSnapshot, + ] { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + drain_insert_history(&mut rx); + let item = patch_item(AppServerPatchApplyStatus::Completed); + let projected = thread_items_to_transcript_cells( + chat.thread_id, + &chat.config.cwd, + [item.clone()], + RawReasoningVisibility::Hidden, + Some(&chat.config), + ); + chat.replay_thread_item(item, "turn".to_string(), replay_kind); + let replayed = take_history_cells(&mut rx); + assert_eq!((projected.len(), replayed.len()), (1, 1)); + for width in [28, 80] { + assert_eq!( + ( + replayed[0].display_hyperlink_lines(width), + replayed[0].transcript_hyperlink_lines(width), + ), + ( + projected[0].display_hyperlink_lines(width), + projected[0].transcript_hyperlink_lines(width), + ) + ); + } + insta::assert_snapshot!( + "snapshot_formatter_completed_patch", + lines_to_single_string(&replayed[0].display_lines(/*width*/ 80)) + ); + } +} + +#[tokio::test] +async fn snapshot_formatter_live_and_buffered_patch_notifications_render_once() { + for replay_kind in [ + None, + Some(ReplayKind::ThreadSnapshot), + Some(ReplayKind::ResumeInitialMessages), + ] { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + drain_insert_history(&mut rx); + chat.handle_server_notification( + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item: patch_item(AppServerPatchApplyStatus::InProgress), + started_at_ms: 0, + }), + replay_kind, + ); + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item: patch_item(AppServerPatchApplyStatus::Completed), + completed_at_ms: 0, + }), + replay_kind, + ); + let replayed = take_history_cells(&mut rx); + assert_eq!(replayed.len(), 1); + insta::assert_snapshot!( + "snapshot_formatter_completed_patch", + lines_to_single_string(&replayed[0].display_lines(/*width*/ 80)) + ); + } +} + +#[tokio::test] +async fn snapshot_formatter_empty_success_and_failed_patch_keep_their_outcomes() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + drain_insert_history(&mut rx); + chat.replay_thread_item( + AppServerThreadItem::FileChange { + id: "empty".to_string(), + changes: Vec::new(), + status: AppServerPatchApplyStatus::Completed, + }, + "turn".to_string(), + ReplayKind::ThreadSnapshot, + ); + chat.replay_thread_item( + patch_item(AppServerPatchApplyStatus::Failed), + "turn".to_string(), + ReplayKind::ThreadSnapshot, + ); + let replayed = take_history_cells(&mut rx); + assert_eq!(replayed.len(), 1); + insta::assert_snapshot!(lines_to_single_string(&replayed[0].display_lines(/*width*/ 80)), @" + ✘ Failed to apply patch + "); +} + +fn patch_item(status: AppServerPatchApplyStatus) -> AppServerThreadItem { + AppServerThreadItem::FileChange { + id: "patch".to_string(), + changes: vec![FileUpdateChange { + path: "sample.txt".to_string(), + kind: PatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@\n-old sample\n+new sample\n".to_string(), + }], + status, + } +} + fn take_history_cells( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec> { diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index c0d90956a5..68f7a1e263 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -661,50 +661,6 @@ async fn windows_sandbox_required_enable_prompt_reopens_on_cancel_when_unelevate )); } -#[tokio::test] -async fn windows_sandbox_pickers_keep_explicit_confirmation_with_remapped_actions() { - for fallback in [false, true] { - let (mut chat, mut events, mut operations) = - make_chatwidget_manual(/*model_override*/ None).await; - let mut keymap = crate::keymap::RuntimeKeymap::defaults(); - keymap.list.accept = vec![key_hint::plain(KeyCode::F(3))]; - keymap.list.cancel = vec![key_hint::plain(KeyCode::F(2))]; - chat.bottom_pane.set_keymap_bindings(&keymap); - chat.bottom_pane - .set_composer_text("Keep this draft 日本語".into(), Vec::new(), Vec::new()); - let draft = chat.bottom_pane.composer_draft_snapshot(); - let preset = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - .expect("auto preset"); - if fallback { - chat.open_windows_sandbox_fallback_prompt(preset, /*profile_selection*/ None); - } else { - chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); - } - while events.try_recv().is_ok() {} - while operations.try_recv().is_ok() {} - - chat.handle_key_event(KeyCode::Char('2').into()); - assert!(chat.has_active_view()); - assert!(events.try_recv().is_err()); - let rendered = render_bottom_popup(&chat, /*width*/ 40); - assert!(rendered.contains("f3 select · f2 back"), "{rendered}"); - assert_chatwidget_snapshot!( - format!("windows_sandbox_picker_fallback_{fallback}_40"), - rendered - ); - - chat.handle_key_event(KeyCode::F(3).into()); - assert_matches!( - events.try_recv(), - Ok(AppEvent::BeginWindowsSandboxLegacySetup { .. }) - ); - assert_eq!(chat.bottom_pane.composer_draft_snapshot(), draft); - assert!(operations.try_recv().is_err()); - } -} - #[tokio::test] async fn fragmented_terminal_response_cannot_select_non_admin_windows_sandbox() { for use_fallback_prompt in [false, true] { diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 0deccaa8df..708d13ddde 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -133,6 +133,7 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); let event = rx.try_recv().expect("expected AppEvent"); let AppEvent::SubmitUserMessageWithMode { text, diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index dfff33a65e..43b68a1ad9 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -258,6 +258,8 @@ async fn slash_side_without_args_starts_empty_side_conversation() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { @@ -283,6 +285,8 @@ async fn slash_btw_without_args_starts_empty_side_conversation() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { @@ -314,6 +318,8 @@ async fn slash_side_requests_forked_side_question_while_task_running() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { @@ -360,6 +366,8 @@ async fn slash_btw_requests_forked_side_question_while_task_running() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 585b5605d4..717f4c0f5c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -185,6 +185,7 @@ async fn queued_slash_compact_dispatches_after_active_turn() { handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/compact"); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( @@ -710,6 +711,7 @@ async fn goal_slash_command_with_extra_os_emits_set_goal_event() { let command = "/goooooooooooal --tokens 98.5K improve benchmark coverage"; submit_composer_text(&mut chat, command); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); let event = rx.try_recv().expect("expected goal draft event"); let AppEvent::SetThreadGoalDraft { @@ -805,6 +807,8 @@ async fn bare_goal_slash_command_drains_pending_submission_state() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::OpenThreadGoalMenu { thread_id: opened }) if opened == thread_id @@ -832,6 +836,7 @@ async fn goal_control_slash_commands_emit_goal_events() { .set_composer_text(command.into(), Vec::new(), Vec::new()); chat.handle_key_event(KeyCode::Esc.into()); chat.handle_key_event(KeyCode::Enter.into()); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); match status { Some(status) => { @@ -888,6 +893,7 @@ async fn goal_edit_slash_command_opens_goal_editor() { chat.thread_id = thread_id; submit_composer_text(&mut chat, "/goal edit"); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); let event = rx.try_recv().expect("expected goal editor event"); let AppEvent::OpenThreadGoalEditor { @@ -1974,6 +1980,8 @@ async fn slash_copy_picker_waits_for_submission_after_typing_or_autocomplete() { assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!(popup.contains("Whole response"), "picker: {popup}"); assert!(chat.bottom_pane.composer_text().is_empty()); @@ -1993,6 +2001,8 @@ async fn slash_copy_picker_waits_for_submission_after_paste() { assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); assert!(chat.bottom_pane.composer_text().is_empty()); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); @@ -2128,6 +2138,7 @@ async fn slash_copy_picker_remains_available_from_parent_owned_threads() { chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response")); chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); @@ -2192,10 +2203,11 @@ async fn slash_export_opens_destination_picker() { } #[tokio::test] -async fn ctrl_o_copy_reports_when_no_agent_response_exists() { +async fn copy_action_reports_when_no_agent_response_exists() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + let (code, modifiers) = crate::keymap::RuntimeKeymap::defaults().app.copy[0].parts(); + chat.handle_key_event(KeyEvent::new(code, modifiers)); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one info message"); @@ -2376,6 +2388,7 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { ))) }); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert!(chat.clipboard_lease.is_some()); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one success message"); @@ -2386,6 +2399,7 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { ); chat.copy_last_agent_markdown_with(|_| Ok(crate::clipboard_copy::CopyOutcome::Requested)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert!(chat.clipboard_lease.is_some()); let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); insta::assert_snapshot!("unconfirmed_copy", rendered); @@ -2395,6 +2409,7 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { Err("blocked".into()) }); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert!(chat.clipboard_lease.is_some()); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one failure message"); @@ -2415,11 +2430,11 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { assert!(rendered.contains("Copied python code to clipboard")); chat.copy_selection_with("terminal copy", "code", |_| { - Ok(crate::clipboard_copy::CopyOutcome::Requested) + Ok(crate::clipboard_copy::CopyOutcome::Copied(None)) }); assert!(chat.clipboard_lease.is_some()); let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); - assert!(rendered.contains("Copy unconfirmed; /export saves chat")); + assert!(rendered.contains("Copied code to clipboard")); chat.copy_selection_with("print('blocked')\n", "python code", |content| { assert_eq!(content, "print('blocked')\n"); @@ -2428,6 +2443,13 @@ async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() { assert!(chat.clipboard_lease.is_some()); let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); assert!(rendered.contains("Copy failed: blocked selection")); + + chat.copy_selection_with("print('ok')\n", "python code", |_| { + Ok(crate::clipboard_copy::CopyOutcome::Requested) + }); + assert!(chat.clipboard_lease.is_some()); + let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]); + assert!(rendered.contains("Copy unconfirmed; /export saves chat")); } #[tokio::test] @@ -2585,6 +2607,8 @@ async fn slash_new_with_name_requests_named_session() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::NewSession { @@ -2601,6 +2625,8 @@ async fn slash_clear_with_name_requests_named_session() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::ClearUi { @@ -2631,6 +2657,7 @@ async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { chat.bottom_pane .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi { name: None })); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); @@ -2728,6 +2755,7 @@ async fn slash_mcp_verbose_requests_full_inventory_via_app_server() { chat.thread_id = Some(thread_id); submit_composer_text(&mut chat, "/mcp verbose"); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert!(active_blob(&chat).contains("Loading MCP inventory")); assert_matches!( @@ -2873,6 +2901,7 @@ async fn slash_resume_with_arg_requests_named_session_while_mcp_startup_is_runni Vec::new(), ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_matches!( rx.try_recv(), @@ -2905,6 +2934,7 @@ async fn slash_pets_with_arg_selects_named_pet() { chat.bottom_pane .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_matches!( rx.try_recv(), @@ -2922,6 +2952,7 @@ async fn slash_pets_disable_disables_pets_even_on_unsupported_terminal() { chat.bottom_pane .set_composer_text("/pets disable".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); @@ -2937,6 +2968,7 @@ async fn slash_pet_hide_disables_pets_even_on_unsupported_terminal() { chat.bottom_pane .set_composer_text("/pet hide".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); @@ -3041,6 +3073,8 @@ async fn slash_fork_with_name_requests_named_fork() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + assert_matches!( rx.try_recv(), Ok(AppEvent::ForkCurrentSession { @@ -3118,6 +3152,10 @@ async fn slash_cd_changes_current_session_after_replay_and_defaults_to_home() { .set_composer_text("/cd".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); for expected in ["/tmp", "~"] { + // Only the interactive submission adds a follow event; queued dispatch already owns it. + if expected == "~" { + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); + } assert_matches!( rx.try_recv(), Ok(AppEvent::ChangeWorkingDirectory { thread_id: actual, requested_cwd }) @@ -3570,3 +3608,50 @@ async fn compact_queues_user_messages_snapshot() { normalize_snapshot_paths(term.backend().vt100().screen().contents()) ); } + +#[tokio::test] +async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interrupts() { + let (mut chat, mut events, mut operations) = + make_chatwidget_manual(/*model_override*/ None).await; + let _ = drain_insert_history(&mut events); + let result = chat.copy_transcript_selection_with("selected café\nsecond line", |text| { + assert_eq!(text, "selected café\nsecond line"); + Ok(crate::clipboard_copy::CopyOutcome::Copied(Some( + crate::clipboard_copy::ClipboardLease::test(), + ))) + }); + assert_eq!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)); + assert_eq!(drain_insert_history(&mut events).len(), 0); + assert!(operations.try_recv().is_err()); + assert!(chat.clipboard_lease.is_some()); + insta::assert_snapshot!( + "transcript_copy_success", + render_bottom_popup(&chat, /*width*/ 80) + ); + let result = chat.copy_transcript_selection_with("selected café", |_text| { + Ok(crate::clipboard_copy::CopyOutcome::Requested) + }); + assert_eq!(result, Ok(crate::clipboard_copy::CopyStatus::Unconfirmed)); + assert_eq!(drain_insert_history(&mut events).len(), 0); + assert!(operations.try_recv().is_err()); + assert!(chat.clipboard_lease.is_some()); + insta::assert_snapshot!( + "transcript_copy_unconfirmed", + render_bottom_popup(&chat, /*width*/ 80) + ); + insta::assert_snapshot!( + "transcript_copy_unconfirmed_narrow", + render_bottom_popup(&chat, /*width*/ 40) + ); + let result = chat.copy_transcript_selection_with("selected café", |_text| { + Err("clipboard unavailable".to_string()) + }); + assert_eq!(result, Err("clipboard unavailable".to_string())); + assert_eq!(drain_insert_history(&mut events).len(), 0); + assert!(operations.try_recv().is_err()); + assert!(chat.clipboard_lease.is_some()); + insta::assert_snapshot!( + "transcript_copy_failure", + render_bottom_popup(&chat, /*width*/ 80) + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__dynamic_activity_tests__terminal_dynamic_activity.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__dynamic_activity_tests__terminal_dynamic_activity.snap deleted file mode 100644 index 4ff773b34c..0000000000 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__dynamic_activity_tests__terminal_dynamic_activity.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tui/src/chatwidget/tests/dynamic_activity_tests.rs -expression: "lines_to_single_string(&outputs[0])" ---- -• Failed test.lookup · 25ms - └ Result for call-1 -• Called test.lookup · 25ms - └ Result for call-2 diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__group_projection_tests__completion_only_replay_exploration_group.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__group_projection_tests__completion_only_replay_exploration_group.snap deleted file mode 100644 index cc2dd6d5f4..0000000000 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__group_projection_tests__completion_only_replay_exploration_group.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui/src/chatwidget/tests/group_projection_tests.rs -expression: "format!(\"legacy:\\n{}\\ndetailed:\\n{}\",\nlines_to_single_string(&exploration.display_lines(40)),\nlines_to_single_string(&exploration.transcript_lines(40)),)" ---- -legacy: -• Explored - └ Read one.rs, two.rs - -detailed: -$ cat one.rs -fn main() {} -✓ • 5ms - -$ cat two.rs -fn main() {} -✓ • 5ms diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__completion_only_replay_exploration_group.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__completion_only_replay_exploration_group.snap new file mode 100644 index 0000000000..ed981d33a6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__completion_only_replay_exploration_group.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests/history_projection.rs +assertion_line: 142 +expression: "format!(\"compact:\\n{}\\ndetailed:\\n{}\",\nlines_to_single_string(&exploration.display_lines(40)),\nlines_to_single_string(&exploration.transcript_lines(40)),)" +--- +compact: +• Explored + └ Read one.rs, two.rs + +detailed: +$ cat one.rs +fn main() {} +✓ • 5ms + +$ cat two.rs +fn main() {} +✓ • 5ms diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__snapshot_formatter_completed_patch.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__snapshot_formatter_completed_patch.snap new file mode 100644 index 0000000000..52c387767a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__snapshot_formatter_completed_patch.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/history_projection.rs +assertion_line: 243 +expression: "lines_to_single_string(&replayed[0].display_lines(80))" +--- +• Edited sample.txt (+1 -1) + 1 -old sample + 1 +new sample diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__group_projection_tests__snapshot_formatter_reasoning_matches_legacy_and_detailed_replay.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__snapshot_formatter_reasoning_matches_compact_and_detailed_replay.snap similarity index 63% rename from codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__group_projection_tests__snapshot_formatter_reasoning_matches_legacy_and_detailed_replay.snap rename to codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__snapshot_formatter_reasoning_matches_compact_and_detailed_replay.snap index 88ae31cbb3..7e063cb5a4 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__group_projection_tests__snapshot_formatter_reasoning_matches_legacy_and_detailed_replay.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__history_projection__snapshot_formatter_reasoning_matches_compact_and_detailed_replay.snap @@ -1,14 +1,15 @@ --- -source: tui/src/chatwidget/tests/group_projection_tests.rs +source: tui/src/chatwidget/tests/history_projection.rs +assertion_line: 176 expression: "snapshots.join(\"\\n\")" --- Hidden -legacy: [] +compact: [] detailed: • Read src/main.rs (src/main.rs:3). Visible -legacy: [] +compact: [] detailed: • Read src/main.rs (src/main.rs:3). diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_failure.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_failure.snap new file mode 100644 index 0000000000..64dc468da2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_failure.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› Ask Codex to do anything + + Copy failed: clipboard unavailable diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_success.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_success.snap new file mode 100644 index 0000000000..073cd11c19 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_success.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› Ask Codex to do anything + + Copied selection to clipboard diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_unconfirmed.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_unconfirmed.snap new file mode 100644 index 0000000000..51efc1d5e0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_unconfirmed.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "render_bottom_popup(&chat, 80)" +--- +› Ask Codex to do anything + + Copy unconfirmed; /export saves chat diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_unconfirmed_narrow.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_unconfirmed_narrow.snap new file mode 100644 index 0000000000..790463ca78 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__transcript_copy_unconfirmed_narrow.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: "render_bottom_popup(&chat, 40)" +--- +› Ask Codex to do anything + + Copy unconfirmed; /export saves chat diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 34b88157e6..c0e1c93e2b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2839,7 +2839,7 @@ async fn warning_event_adds_warning_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; handle_warning(&mut chat, "test warning message"); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_transcript(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); let rendered = lines_to_single_string(&cells[0]); assert!( @@ -2856,7 +2856,7 @@ async fn unsupported_code_mode_warning_renders_as_warning_history_cell() { "Code Mode is enabled in configuration, but model `gpt-5.4` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode.", ); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_transcript(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); insta::assert_snapshot!( "unsupported_code_mode_warning", @@ -2872,7 +2872,7 @@ async fn repeated_model_metadata_warning_is_hidden_for_same_slug() { handle_warning(&mut chat, warning); handle_warning(&mut chat, warning); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_transcript(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); let rendered = lines_to_single_string(&cells[0]); assert!( @@ -2904,7 +2904,7 @@ async fn status_line_invalid_items_warn_once() { chat.thread_id = Some(ThreadId::new()); chat.refresh_status_line(); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_transcript(&mut rx); assert_eq!(cells.len(), 1, "expected one warning history cell"); let rendered = lines_to_single_string(&cells[0]); assert!( @@ -2913,7 +2913,7 @@ async fn status_line_invalid_items_warn_once() { ); chat.refresh_status_line(); - let cells = drain_insert_history(&mut rx); + let cells = drain_insert_history_transcript(&mut rx); assert!( cells.is_empty(), "expected invalid status line warning to emit only once" diff --git a/codex-rs/tui/src/chatwidget/tests/tool_details_tests.rs b/codex-rs/tui/src/chatwidget/tests/tool_details_tests.rs deleted file mode 100644 index c380e67dc6..0000000000 --- a/codex-rs/tui/src/chatwidget/tests/tool_details_tests.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Live completion, replay, and picker projection share retained tool details. - -use super::*; -use crate::thread_transcript::RawReasoningVisibility; -use crate::thread_transcript::thread_items_to_transcript_cells; -use pretty_assertions::assert_eq; - -#[tokio::test] -async fn completed_tool_details_match_live_replay_and_picker_projection() { - let output = (1..=12) - .map(|line| format!("result line {line}")) - .collect::>() - .join("\n"); - let command: AppServerThreadItem = serde_json::from_value(json!({ - "type": "commandExecution", "id": "command", "command": "cargo check", - "cwd": test_path_buf("/workspace"), "source": "agent", - "status": "completed", "commandActions": [{"type": "unknown", "command": "cargo check"}], - "aggregatedOutput": output, "exitCode": 0, "durationMs": 5 - })) - .expect("completed command"); - let mcp: AppServerThreadItem = serde_json::from_value(json!({ - "type": "mcpToolCall", "id": "mcp", "server": "example", "tool": "read", - "status": "completed", "arguments": {"path": "source.rs"}, - "result": {"content": [{"type": "text", "text": output}]}, "durationMs": 5 - })) - .expect("completed MCP result"); - for item in [command, mcp] { - let mut projections = Vec::new(); - for replay in [false, true] { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - drain_insert_history(&mut rx); - chat.handle_paste("Unsent draft".to_owned()); - if replay { - chat.replay_thread_item( - item.clone(), - "turn".to_owned(), - ReplayKind::ThreadSnapshot, - ); - } else { - match item.clone() { - item @ AppServerThreadItem::CommandExecution { .. } => { - chat.handle_command_execution_completed_now(item) - } - item @ AppServerThreadItem::McpToolCall { .. } => { - chat.handle_mcp_tool_call_completed_now(item) - } - _ => unreachable!("tool fixture"), - } - } - chat.flush_active_cell(); - let mut retained = Vec::new(); - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - retained.push(( - cell.transcript_hyperlink_lines(/*width*/ 32), - cell.raw_lines(), - )); - } - } - assert_eq!(chat.composer_text_with_pending(), "Unsent draft"); - projections.push(retained); - } - let picker = thread_items_to_transcript_cells( - /*thread_id*/ None, - &test_path_buf("/workspace").abs(), - [item], - RawReasoningVisibility::Hidden, - /*config*/ None, - ); - projections.push( - picker - .iter() - .map(|cell| { - ( - cell.transcript_hyperlink_lines(/*width*/ 32), - cell.raw_lines(), - ) - }) - .collect(), - ); - assert_eq!(projections[0].len(), 1); - for projection in &projections[1..] { - assert_eq!(&projections[0], projection); - } - let raw = projections[0][0] - .1 - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); - assert!(raw.contains(&output)); - } -} diff --git a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs index 0d2a821de8..5fdc5b3628 100644 --- a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs @@ -39,11 +39,13 @@ async fn slash_new_and_fork_offer_checkout_choices_inside_local_git_repository() chat.bottom_pane .set_composer_text("/new named".into(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::NewSession { name: Some(name) }) if name == "named"); chat.bottom_pane .set_composer_text("/fork named".into(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript)); chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::StartManagedWorktree { mode: crate::app_event::ManagedWorktreeMode::Fork, diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 1529734c02..f7e648bff1 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -508,10 +508,10 @@ mod tests { #[test] fn ssh_attempts_both_native_and_osc52() { - let tmux_calls = Cell::new(0_u8); - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let tmux_calls = Cell::new(/*value*/ 0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "**hello**", CopyFormat::Markdown, @@ -544,10 +544,10 @@ mod tests { #[test] fn ssh_reports_failure_when_all_backends_fail() { - let tmux_calls = Cell::new(0_u8); - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let tmux_calls = Cell::new(/*value*/ 0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -585,10 +585,10 @@ mod tests { #[test] fn ssh_inside_tmux_attempts_both_native_and_tmux() { - let tmux_calls = Cell::new(0_u8); - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let tmux_calls = Cell::new(/*value*/ 0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -620,10 +620,10 @@ mod tests { #[test] fn ssh_inside_tmux_falls_back_to_osc52_when_tmux_copy_fails() { - let tmux_calls = Cell::new(0_u8); - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let tmux_calls = Cell::new(/*value*/ 0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -677,9 +677,9 @@ mod tests { #[test] fn local_uses_native_clipboard_first() { - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -732,9 +732,9 @@ mod tests { #[test] fn local_non_wsl_falls_back_to_osc52_when_native_fails() { - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "**hello**", CopyFormat::Markdown, @@ -801,9 +801,9 @@ mod tests { #[test] fn local_wsl_native_failure_uses_powershell_and_skips_osc52_on_success() { - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -831,9 +831,9 @@ mod tests { #[test] fn local_wsl_falls_back_to_osc52_when_native_and_powershell_fail() { - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -861,9 +861,9 @@ mod tests { #[test] fn local_reports_both_errors_when_native_and_osc52_fail() { - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, @@ -897,9 +897,9 @@ mod tests { #[test] fn local_wsl_reports_native_powershell_and_osc52_errors_when_all_fail() { - let osc_calls = Cell::new(0_u8); - let native_calls = Cell::new(0_u8); - let wsl_calls = Cell::new(0_u8); + let osc_calls = Cell::new(/*value*/ 0_u8); + let native_calls = Cell::new(/*value*/ 0_u8); + let wsl_calls = Cell::new(/*value*/ 0_u8); let result = copy_to_clipboard_with( "hello", CopyFormat::PlainText, diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs index 90bbdcd77d..c67888757e 100644 --- a/codex-rs/tui/src/config_update_tests.rs +++ b/codex-rs/tui/src/config_update_tests.rs @@ -139,7 +139,7 @@ async fn remote_project_trust_guards_thread_start_and_preserves_repository_decis ); insta::assert_snapshot!( warning - .display_lines(/*width*/ 80) + .transcript_lines(/*width*/ 80) .iter() .map(ToString::to_string) .collect::>() diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap index 1cd824891c..6b8ee52b85 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap @@ -1,5 +1,5 @@ --- -source: tui/src/history_cell/tests.rs +source: tui/src/history_cell.rs expression: rendered --- • Ran first_token_is_long_en diff --git a/codex-rs/tui/src/keymap/voice_tests.rs b/codex-rs/tui/src/keymap/voice_tests.rs index 3fe76a9e9a..752618b952 100644 --- a/codex-rs/tui/src/keymap/voice_tests.rs +++ b/codex-rs/tui/src/keymap/voice_tests.rs @@ -65,7 +65,7 @@ fn voice_mute_chord_dispatches_only_in_its_active_context() { matcher.advance( prefix, &runtime.chords, - KeymapContextSet::new(KeymapContext::Pager), + KeymapContextSet::new(KeymapContext::Pager) ), KeyChordMatch::PassThrough ); @@ -142,7 +142,7 @@ fn voice_toggle_chord_dispatches_in_chat_without_an_active_voice_session() { matcher.advance( prefix, &runtime.chords, - KeymapContextSet::new(KeymapContext::Pager), + KeymapContextSet::new(KeymapContext::Pager) ), KeyChordMatch::PassThrough ); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 2579499e2a..51773641f9 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -206,7 +206,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("pager", "Pager", "jump_bottom", "Jump to the end."), action("pager", "Pager", "close", "Close the pager overlay."), action("pager", "Pager", "close_transcript", "Close the transcript overlay."), - action("pager", "Pager", "find", "Find text in the transcript overlay."), + action("pager", "Pager", "find", "Find text in a transcript pager."), action("list", "List", "move_up", "Move list selection up."), action("list", "List", "move_down", "Move list selection down."), action("list", "List", "move_left", "Move horizontally left in list pickers."), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ff27a18ff5..aecee78f49 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2228,6 +2228,7 @@ pub(crate) mod tests { async fn build_config(temp_dir: &TempDir) -> std::io::Result { ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(temp_dir.path().to_path_buf()) .build() .await diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 1604be9b29..c81112b444 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -6,6 +6,7 @@ use crate::history_cell::PlainHistoryCell; use crate::render::line_utils::prefix_lines; +use crate::style::accent_color; use crate::text_formatting::truncate_text; use codex_app_server_protocol::CollabAgentState; use codex_app_server_protocol::CollabAgentStatus; @@ -318,16 +319,6 @@ pub(crate) fn sub_agent_activity_history_cell(item: &ThreadItem) -> Option String { - match kind { - SubAgentActivityKind::Started => format!("Started `{agent_path}`"), - SubAgentActivityKind::Interacted => format!("Interacted with `{agent_path}`"), - SubAgentActivityKind::Interrupted => format!("Interrupted `{agent_path}`"), - SubAgentActivityKind::Completed => format!("Completed `{agent_path}`"), - } -} - fn sub_agent_activity_title(kind: SubAgentActivityKind, agent_path: &str) -> Line<'static> { let (prefix, path) = match kind { SubAgentActivityKind::Started => ("Started ", agent_path), @@ -337,7 +328,7 @@ fn sub_agent_activity_title(kind: SubAgentActivityKind, agent_path: &str) -> Lin }; title_spans_line(vec![ Span::from(prefix).bold(), - Span::from(format!("`{path}`")).cyan(), + Span::from(format!("`{path}`")).fg(accent_color()), ]) } @@ -521,11 +512,11 @@ fn agent_label_spans(agent: AgentLabel<'_>) -> Vec> { let role = agent.role.map(str::trim).filter(|role| !role.is_empty()); if let Some(nickname) = nickname { - spans.push(Span::from(nickname.to_string()).cyan().bold()); + spans.push(Span::from(nickname.to_string()).fg(accent_color()).bold()); } else if let Some(thread_id) = agent.thread_id { - spans.push(Span::from(thread_id.to_string()).cyan()); + spans.push(Span::from(thread_id.to_string()).fg(accent_color())); } else { - spans.push(Span::from("agent").cyan()); + spans.push(Span::from("agent").fg(accent_color())); } if let Some(role) = role { @@ -633,8 +624,8 @@ fn status_summary_line(status: Option<&CollabAgentState>, fallback_error: &str) fn status_summary_spans(status: &CollabAgentState) -> Vec> { match status.status { - CollabAgentStatus::PendingInit => vec![Span::from("Pending init").cyan()], - CollabAgentStatus::Running => vec![Span::from("Running").cyan().bold()], + CollabAgentStatus::PendingInit => vec![Span::from("Pending init").fg(accent_color())], + CollabAgentStatus::Running => vec![Span::from("Running").fg(accent_color()).bold()], // Allow `.yellow()` #[allow(clippy::disallowed_methods)] CollabAgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()], @@ -918,7 +909,7 @@ mod tests { let lines = cell.display_lines(/*width*/ 200); let title = &lines[0]; assert_eq!(title.spans[2].content.as_ref(), "Robie"); - assert_eq!(title.spans[2].style.fg, Some(Color::Cyan)); + assert_eq!(title.spans[2].style.fg, Some(accent_color())); assert!(title.spans[2].style.add_modifier.contains(Modifier::BOLD)); assert_eq!(title.spans[4].content.as_ref(), "[explorer]"); assert_eq!(title.spans[4].style.fg, None); diff --git a/codex-rs/tui/src/session_start_tests.rs b/codex-rs/tui/src/session_start_tests.rs index 8c020272bb..3be0a4d0ac 100644 --- a/codex-rs/tui/src/session_start_tests.rs +++ b/codex-rs/tui/src/session_start_tests.rs @@ -1,5 +1,3 @@ -//! Archived-session confirmation retries retain the first request settings. - use super::*; use crate::legacy_core::config::ConfigBuilder; use app_test_support::create_fake_rollout; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a6fec3c17e..3b156ef458 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -131,7 +131,7 @@ impl SlashCommand { SlashCommand::Plan => "switch to Plan mode", SlashCommand::Voice => "start or stop voice; use /voice settings to choose a voice", SlashCommand::Goal => "set or view the goal for a long-running task", - SlashCommand::Agents => "view and switch between all active agent sessions", + SlashCommand::Agents => "open the agent command center", SlashCommand::MultiAgents => "switch between this session's subagents", SlashCommand::Side | SlashCommand::Btw => { "start a side conversation in an ephemeral fork" diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 185911ce23..faedbf0a5c 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -525,7 +525,7 @@ fn startup_draft_renderable<'a>( renderable.push( /*flex*/ 0, bottom_pane - .as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) + .as_renderable_with_options(Default::default()) .inset(Insets::tlbr( /*top*/ u16::from(loading_message.is_none()), /*left*/ 0, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index ad79dcf6f2..043eef62cd 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -5,6 +5,7 @@ use crate::history_cell::plain_lines; use crate::history_cell::with_border_with_inner_width; use crate::legacy_core::config::Config; use crate::line_truncation::line_width; +use crate::style::accent_color; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; use crate::version::CODEX_CLI_VERSION; @@ -804,12 +805,12 @@ impl StatusHistoryCell { let value_width = formatter.value_width(available_inner_width); let note_first_line = Line::from(vec![ - Span::from("Visit ").cyan(), - CHATGPT_USAGE_URL.cyan().underlined(), - Span::from(" for up-to-date").cyan(), + Span::from("Visit ").fg(accent_color()), + CHATGPT_USAGE_URL.fg(accent_color()).underlined(), + Span::from(" for up-to-date").fg(accent_color()), ]); let note_second_line = Line::from(vec![ - Span::from("information on rate limits and credits").cyan(), + Span::from("information on rate limits and credits").fg(accent_color()), ]); let note_lines = adaptive_wrap_lines( [note_first_line, note_second_line],