diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 3d96f7678a..6243de9c75 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -938,6 +938,11 @@ pub struct Tui { #[serde(default = "default_true")] pub fullscreen_transcript: bool, + /// Preferred docked sidebar width as a percentage of the terminal. Defaults to one third. + #[serde(default)] + #[schemars(range(min = 1, max = 99))] + pub sidebar_width_percent: Option, + /// Mouse wheel speed multiplier for transcript scrolling, based on one row per event. /// Defaults to `1.0`. Positive fractional values slow scrolling; values above `1.0` speed it up. #[serde(default, deserialize_with = "crate::tui_mouse_scroll::deserialize")] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b1b232d780..3ad4a9d9da 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5573,6 +5573,14 @@ "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", "type": "boolean" }, + "sidebar_width_percent": { + "default": null, + "description": "Preferred docked sidebar width as a percentage of the terminal. Defaults to one third.", + "format": "uint8", + "maximum": 99.0, + "minimum": 1.0, + "type": "integer" + }, "status_line": { "default": null, "description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line. When unset, the TUI defaults to: `model-with-reasoning`, `current-dir`, and `thread-name`.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index fc48a2aaf0..3970fac710 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1404,6 +1404,7 @@ fn config_toml_deserializes_model_availability_nux() { question_esc_back: true, raw_output_mode: false, fullscreen_transcript: true, + sidebar_width_percent: None, mouse_scroll_speed: None, copy_on_select: Default::default(), copy_on_select_clear_selection: false, @@ -4766,6 +4767,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { question_esc_back: true, raw_output_mode: false, fullscreen_transcript: true, + sidebar_width_percent: None, mouse_scroll_speed: None, copy_on_select: Default::default(), copy_on_select_clear_selection: false, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8f83856adb..7f934808be 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -846,6 +846,9 @@ pub struct Config { /// Own the fullscreen transcript when the alternate screen is enabled. pub tui_fullscreen_transcript: bool, + /// Preferred docked sidebar width as a percentage of the terminal. + pub tui_sidebar_width_percent: Option, + /// Mouse wheel speed multiplier for transcript scrolling; defaults to one row per event. pub tui_mouse_scroll_speed: Option, @@ -4883,6 +4886,7 @@ impl Config { .tui .as_ref() .is_none_or(|tui| tui.fullscreen_transcript), + tui_sidebar_width_percent: cfg.tui.as_ref().and_then(|tui| tui.sidebar_width_percent), tui_mouse_scroll_speed: cfg.tui.as_ref().and_then(|tui| tui.mouse_scroll_speed), tui_copy_on_select: cfg .tui diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index bc50f41481..e7e8ed4c7b 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -302,6 +302,7 @@ async fn new_config( tui_auto_recap: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), tui_fullscreen_transcript: false, + tui_sidebar_width_percent: None, tui_mouse_scroll_speed: None, tui_copy_on_select: Default::default(), tui_copy_on_select_clear_selection: false, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ed38e821b5..0d062fb6da 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -169,6 +169,8 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEventKind; use ratatui::layout::Rect; use ratatui::layout::Size; use ratatui::style::Stylize; @@ -270,6 +272,7 @@ mod server_version_notice; mod session_lifecycle; mod session_picker; mod side; +mod side_companion; mod side_focus; mod side_pane; mod startup; @@ -302,10 +305,10 @@ use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; pub(crate) use self::platform_actions::WindowsSandboxHost; use self::platform_actions::*; -use self::side::SideCompanion; use self::side::SideParentStatus; use self::side::SideParentStatusChange; use self::side::SideThreadState; +use self::side_companion::SideCompanion; use self::startup_prompts::*; use self::thread_events::*; @@ -651,6 +654,8 @@ pub(crate) struct App { side_threads: HashMap, /// A parked parent view survives child cleanup until its own replay finishes. side_companions: HashMap, + divider_drag: Option, + passive_link_drag: Option<(ThreadId, Rect)>, abandoned_side_threads: HashSet, active_thread_id: Option, active_thread_rx: Option>, @@ -917,7 +922,10 @@ impl App { } self.backtrack.select_exit_key = None; } - if self.handle_rendered_selection_event(tui, &event)? { + if self.continue_passive_link_drag(tui, &event)? + || self.handle_divider_event(tui, &event).await? + || self.handle_rendered_selection_event(tui, &event)? + { return Ok(AppRunControl::Continue); } if matches!(&event, TuiEvent::Key(_)) @@ -945,6 +953,19 @@ impl App { } _ => false, }; + if let TuiEvent::Key(key) = &event + && crate::text_selection::is_copy_key(*key) + && !transcript_owns_input + && self.conversation_focus_available(tui) + && let Some(painted) = &self.conversation_frame.side_pane + && let Some(current) = + side_pane::layout(self, tui, tui.terminal.last_known_screen_size.into()) + && current.passive_thread == painted.passive_thread + && self.handle_passive_transcript_event(tui, current.passive_thread, &event) + { + self.cancel_pending_key_chord(); + return Ok(AppRunControl::Continue); + } if self.reconnect.offline && !transcript_owns_input && !self.chat_widget.keymap_contexts().is_warnings() @@ -1017,6 +1038,52 @@ impl App { { return Ok(AppRunControl::Continue); } + if let TuiEvent::Mouse(press) = &event + && press.kind == MouseEventKind::Down(MouseButton::Left) + && self.conversation_focus_available(tui) + && let Some(painted) = &self.conversation_frame.side_pane + && painted.passive.contains((press.column, press.row).into()) + { + // A hidden, resized or replaced pane can still be in the last painted frame. + let size = tui.prepare_draw_size()?; + if let Some(current) = side_pane::layout(self, tui, size.into()) + && current.passive_thread == painted.passive_thread + && current.passive == painted.passive + { + if let Some(url) = self.transcript_link_at(*press) { + if press.modifiers.is_empty() { + self.passive_link_drag = Some((current.passive_thread, current.passive)); + self.handle_passive_transcript_event(tui, current.passive_thread, &event); + } else { + self.open_url_in_browser(url); + } + return Ok(AppRunControl::Continue); + } + if self.reconnect.offline { + return Ok(AppRunControl::Continue); + } + let draft_cursor = self + .side_companions + .get_mut(¤t.passive_thread) + .and_then(|pane| { + let draft = &mut pane.display.draft; + draft + .handle_mouse(*press, Default::default()) + .then(|| draft.cursor()) + }); + if let Err(err) = self.toggle_side_conversation(tui, app_server).await { + self.chat_widget.add_error_message(err.to_string()); + } else if self.current_displayed_thread_id() == Some(current.passive_thread) { + self.app_event_tx.send(AppEvent::SidePanePressAfterReplay { + draft_cursor, + thread_id: current.passive_thread, + area: current.passive, + press: *press, + }); + } + } + return Ok(AppRunControl::Continue); + } self.cancel_primed_browsing_for_event(&event); let voice_toggle = |app: &Self, key: KeyEvent| { key.kind == KeyEventKind::Press diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index 40b882ed18..28cdb5edcb 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -66,6 +66,21 @@ impl App { &mut *selection; view.finish_copy(cells, completion, current); } + let passive = self + .conversation_frame + .side_pane + .as_ref() + .map(|panes| panes.passive_thread); + for (id, companion) in &mut self.side_companions { + let visible = current && passive == Some(*id); + if companion + .view + .finish_copy(&companion.cells, completion, visible) + == Some(true) + { + companion.view.jump_to_latest(); + } + } let follow = self.transcript_view .finish_copy(&self.transcript_cells, completion, current); @@ -85,6 +100,9 @@ impl App { } if !current || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) { self.transcript_view.cancel_primary(); + for companion in self.side_companions.values_mut() { + companion.view.cancel_primary(); + } self.chat_widget.cancel_composer_primary(); self.chat_widget .rendered_selection diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ffa3a3fc02..10ff08ee6f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -450,6 +450,10 @@ impl App { } tui.frame_requester().schedule_frame(); } + if let Some(pane) = self.side_companions.get_mut(&thread_id) { + pane.view.history = TranscriptHistoryState::Failed; + tui.frame_requester().schedule_frame(); + } tracing::warn!(%thread_id, error = %err, "failed to load older transcript history"); } } @@ -944,11 +948,14 @@ impl App { AppEvent::ParkSideCompanion { pair, thread_id, stream, display } => { if self.side_threads.contains_key(&pair) { self.transcript_view.sync_history_tail(&self.transcript_cells); + let mut answer = None; if let Some(stream) = stream { let cells = &mut self.transcript_cells; let (start, replacement): (_, Arc) = match stream { - CapturedSideStream::Answer(cell) => { - (trailing_run_start::(cells), cell) + CapturedSideStream::Answer { cell, item } => { + let start = trailing_run_start::(cells); + answer = item.map(|item| super::side_companion::SideAnswer::new(item, start)); + (start, cell) } CapturedSideStream::Plan(cell) => { (trailing_run_start::(cells), cell) @@ -960,8 +967,10 @@ impl App { } self.side_companions.insert(thread_id, SideCompanion { display, + parked_tail: self.transcript_cells.len(), cells: std::mem::take(&mut self.transcript_cells), view: std::mem::take(&mut self.transcript_view), + answer, }); } } @@ -1002,6 +1011,26 @@ impl App { tui.frame_requester().schedule_frame(); } } + AppEvent::SidePanePressAfterReplay { thread_id, area, mut press, draft_cursor } => { + if self.pending_thread_switch_resets == 0 + && self.current_displayed_thread_id() == Some(thread_id) + && self.conversation_focus_available(tui) + { + // The native mouse handler draws at this reserved size before hit testing. + let size = tui.prepare_draw_size()?; + if side_pane::layout(self, tui, size.into()).is_some_and(|panes| panes.focused == area) { + if let Some(cursor) = draft_cursor { + // Focus can expand or scroll the editor. Replay at the same text position. + self.chat_widget.set_composer_cursor(cursor); + self.refresh_owned_transcript(tui)?; + let position = tui.terminal.last_known_cursor_pos; + press.column = position.x; + press.row = position.y; + } + self.handle_owned_transcript_event(tui, app_server, &TuiEvent::Mouse(press))?; + } + } + } AppEvent::ResetTranscriptForThreadSwitchPreservingScreen => { self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index 8ee444d6f9..5dba74c8ec 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -70,9 +70,10 @@ impl App { return Ok(()); } // The widget can already belong to the destination while the transcript still replays. - if self.chat_widget.thread_id() != Some(thread_id) - || self.awaiting_side_replay(tui) - || (tui.is_owned_screen() && !self.scrollback_has_older_history) + let parked = self.chat_widget.thread_id() != Some(thread_id); + if self.awaiting_side_replay(tui) + || (parked && !self.side_companions.contains_key(&thread_id)) + || (!parked && tui.is_owned_screen() && !self.scrollback_has_older_history) { app_server.cancel_older_history_page(thread_id, cursor); return Ok(()); @@ -115,6 +116,20 @@ impl App { &cwd, visibility, ); + if let Some(pane) = self.side_companions.get_mut(&thread_id).filter(|_| parked) { + let index = history_prefix_len(&pane.cells); + let inserted = index..index + cells.len(); + pane.splice_older_cells(index..index, cells); + pane.view.history_loaded(&pane.cells, inserted); + pane.view.history = if app_server.has_older_history(thread_id) { + TranscriptHistoryState::Partial + } else { + TranscriptHistoryState::Complete + }; + merge_older_turns(&mut store.lock().await.turns, turns); + tui.frame_requester().schedule_frame(); + return Ok(()); + } let inserted = self.prepend_older_transcript_cells(cells); self.transcript_view .history_loaded(&self.transcript_cells, inserted.clone()); @@ -173,6 +188,17 @@ impl App { .zip(projected_user_cells) .map(|((item_id, _), cell)| (item_id, cell)) .collect(); + if self.chat_widget.thread_id() != Some(thread_id) + && let Some(pane) = self.side_companions.get_mut(&thread_id) + { + for index in + hidden_transcript_indices(&pane.cells, persisted_user_cells, hidden_item_ids) + { + pane.splice_older_cells(index..index + 1, Vec::new()); + pane.view.restart_search(); + } + return; + } let indices = hidden_transcript_indices( &self.transcript_cells, persisted_user_cells, @@ -239,9 +265,20 @@ impl App { )); } if let Some(turn) = completed_turn - && let Some(completion) = self - .chat_widget - .completion_cell(turn, Some(ReplayKind::ResumeInitialMessages)) + && let Some(completion) = if let Some(pane) = + self.side_companions.get_mut(&thread_id) + && self.chat_widget.thread_id() != Some(thread_id) + { + history_cell::FinalMessageSeparator::from_turn_once( + turn, + &mut pane.display.rendered_completion_turn_ids, + /*fallback_elapsed*/ None, + /*fallback_completed_at*/ None, + ) + } else { + self.chat_widget + .completion_cell(turn, Some(ReplayKind::ResumeInitialMessages)) + } { cells.push(Arc::new(completion)); } @@ -267,10 +304,7 @@ impl App { let index = if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { overlay.prepend(cells.clone()) } else { - self.transcript_cells - .iter() - .rposition(|cell| is_session_history_prefix_cell(cell.as_ref())) - .map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1)) + history_prefix_len(&self.transcript_cells) }; let inserted = index..index + cells.len(); self.transcript_cells.splice(index..index, cells); @@ -388,6 +422,13 @@ fn hidden_review_item_ids(turns: &[Turn]) -> HashSet<&str> { hidden } +fn history_prefix_len(cells: &[Arc]) -> usize { + cells + .iter() + .rposition(|cell| is_session_history_prefix_cell(cell.as_ref())) + .map_or(/*default*/ 0, |index| index + 1) +} + /// Match repeated user messages from newest to oldest so identical visible prompts stay distinct. fn hidden_transcript_indices( cells: &[Arc], diff --git a/codex-rs/tui/src/app/link_hover.rs b/codex-rs/tui/src/app/link_hover.rs index ded238824e..3e98ac92cb 100644 --- a/codex-rs/tui/src/app/link_hover.rs +++ b/codex-rs/tui/src/app/link_hover.rs @@ -1,26 +1,160 @@ -//! Match transcript hover to the links Codex opens while terminal mouse input is captured. +//! Match hover feedback to transcript links and the pane divider. Keep passive link drags with their original view. //! Recheck after painting so scrolling and live output cannot leave a hand over stale content. +use codex_protocol::ThreadId; use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton::Left; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind::Drag; +use crossterm::event::MouseEventKind::Moved; +use crossterm::event::MouseEventKind::ScrollDown; +use crossterm::event::MouseEventKind::ScrollUp; +use crossterm::event::MouseEventKind::Up; use super::App; +use crate::transcript_view::ViewAction; +use crate::tui::PointerShape; use crate::tui::Tui; +use crate::tui::TuiEvent; impl App { + pub(super) fn transcript_link_at(&self, mouse: MouseEvent) -> Option { + if !mouse.modifiers.is_empty() + && !mouse + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) + { + return None; + } + let view = if let Some(panes) = &self.conversation_frame.side_pane + && panes.passive.contains((mouse.column, mouse.row).into()) + { + let passive = &self.side_companions.get(&panes.passive_thread)?.view; + if passive.is_select_mode() { + return None; + } + passive + } else { + &self.transcript_view + }; + view.link_at(mouse.column, mouse.row) + } + + pub(super) fn handle_passive_transcript_event( + &mut self, + tui: &mut Tui, + id: ThreadId, + event: &TuiEvent, + ) -> bool { + let Some(companion) = self.side_companions.get_mut(&id) else { + return false; + }; + let view = &mut companion.view; + view.mouse_scroll_speed = self.transcript_view.mouse_scroll_speed; + view.copy_on_select = self.transcript_view.copy_on_select; + view.copy_on_select_clear_selection = self.transcript_view.copy_on_select_clear_selection; + view.primary_selection = self.transcript_view.primary_selection; + let action = match event { + TuiEvent::Mouse(mouse) => view.handle_mouse(*mouse, &companion.cells), + TuiEvent::Key(key) if crate::text_selection::is_copy_key(*key) => view + .handle_selection_key(*key, &companion.cells) + .filter(|action| matches!(action, ViewAction::Copy(_))), + _ => None, + }; + let Some(action) = action else { + return false; + }; + if let Some((count, result)) = view.copy_action( + tui, + &companion.cells, + &action, + crate::clipboard_copy::CopyFormat::Markdown, + ) { + self.transcript_view.show_copy_feedback(&result, count); + } else if let ViewAction::OpenLink(url) = action { + self.open_url_in_browser(url); + } + tui.frame_requester().schedule_frame(); + true + } + + pub(super) fn continue_passive_link_drag( + &mut self, + tui: &mut Tui, + event: &TuiEvent, + ) -> color_eyre::eyre::Result { + let Some((id, area)) = self.passive_link_drag else { + return Ok(false); + }; + if self.conversation_focus_available(tui) { + match event { + TuiEvent::Draw => { + if let Some(companion) = self.side_companions.get_mut(&id) + && companion.view.tick_selection(&companion.cells) + { + tui.frame_requester() + .schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL); + } + return Ok(false); + } + TuiEvent::Mouse(mouse) if mouse.kind == Moved => return Ok(false), + TuiEvent::Mouse(mouse) + if matches!(mouse.kind, Drag(Left) | Up(Left) | ScrollUp | ScrollDown) => + { + self.refresh_owned_transcript(tui)?; + if self + .conversation_frame + .side_pane + .as_ref() + .is_some_and(|panes| panes.passive_thread == id && panes.passive == area) + { + self.handle_passive_transcript_event(tui, id, event); + if mouse.kind == Up(Left) { + self.passive_link_drag = None; + } + return Ok(true); + } + } + _ => {} + } + } + self.passive_link_drag = None; + if let Some(companion) = self.side_companions.get_mut(&id) { + companion.view.end_drag(); + } else if self.current_displayed_thread_id() == Some(id) { + self.transcript_view.end_drag(); + } + Ok(false) + } + pub(super) fn refresh_link_hover(&self, tui: &mut Tui) -> std::io::Result<()> { - let over_link = tui.is_owned_screen() - && self.overlay.is_none() - && self.chat_widget.no_modal_or_popup_active() - && tui.link_hover.mouse.is_some_and(|mouse| { - (mouse.modifiers.is_empty() - || mouse - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER)) - && self - .transcript_view - .link_at(mouse.column, mouse.row) - .is_some() - }); - tui.set_link_pointer(over_link) + let shape = if !tui.is_owned_screen() + || self.overlay.is_some() + || !self.chat_widget.no_modal_or_popup_active() + { + PointerShape::Default + } else if self.divider_drag.is_some() + || tui.link_hover.mouse.is_some_and(|mouse| { + self.conversation_frame + .side_pane + .as_ref() + .is_some_and(|panes| panes.divider_contains(mouse)) + }) + { + PointerShape::Resize + } else if tui + .link_hover + .mouse + .is_some_and(|mouse| self.transcript_link_at(mouse).is_some()) + { + PointerShape::Link + } else { + PointerShape::Default + }; + if (shape == PointerShape::Resize) != (tui.link_hover.shape == PointerShape::Resize) { + tui.frame_requester().schedule_frame(); + } + tui.link_hover.shape = shape; + tui.set_pointer_shape(shape) } } diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 831af8dc7e..154559cfec 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -25,18 +25,25 @@ use crate::transcript_image::ImagePlacement; use crate::transcript_view::TranscriptView; use crate::transcript_view::ViewAction; use crate::turn_tip::TurnTip; +use crossterm::event::MouseEventKind; use ratatui::widgets::Widget; /// Remember the last conversation bounds and neighboring content for input-triggered redraws. #[derive(Default)] pub(super) struct ConversationFrame { inset: Option, - side_pane: bool, + pub(super) side_pane: Option, /// A dialog can obscure neighboring content in the terminal's completed buffer. behind_dialog: Option, caller_images: Vec, } +impl ConversationFrame { + pub(super) fn has_caller_inset(&self) -> bool { + self.inset.is_some() && self.side_pane.is_none() + } +} + pub(super) struct PreparedConversation { area: Rect, transcript_width: u16, @@ -196,19 +203,22 @@ impl App { screen_size: Size, ) -> Result { let frames = tui.frame_requester(); - if self.conversation_frame.side_pane { + if self.conversation_frame.side_pane.is_some() { // Recompute our own inset on every frame, including the first narrow frame. self.conversation_frame = ConversationFrame::default(); } let caller_inset = self.conversation_frame.inset; - let dock = if caller_inset.is_none() { - side_pane::layout(self, tui, screen_size.into()) - } else { - None - }; + let dock = side_pane::layout(self, tui, screen_size.into()); + if self.divider_drag.is_some_and(|drag| { + !drag.matches(screen_size.into(), dock.as_ref()) + || !self.conversation_focus_available(tui) + }) { + self.divider_drag = None; + } let inset = caller_inset.or_else(|| dock.as_ref().map(|panes| panes.focused)); let prepared = self.prepare_conversation(screen_size.into(), inset, &frames); - self.conversation_frame.side_pane = dock.is_some(); + self.conversation_frame.side_pane = dock; + let divider_hovered = tui.link_hover.shape == tui::PointerShape::Resize; // An input refresh opens a fresh terminal buffer. Carry the outer owner's // completed cells through it when only the conversation is being redrawn. let backdrop = caller_inset.map(|_| { @@ -236,11 +246,17 @@ impl App { } } painted = self.paint_conversation(frame, &prepared); - if let Some(panes) = dock + if let Some(panes) = &self.conversation_frame.side_pane && let Some(companion) = self.side_companions.get_mut(&panes.passive_thread) { companion.view.image_protocol = self.transcript_view.image_protocol; - panes.paint_companion(companion, frame); + panes.paint_companion( + companion, + frame, + self.divider_drag, + divider_hovered, + &frames, + ); } self.paint_centered_dialog(frame); })?; @@ -254,7 +270,7 @@ impl App { frames: &tui::FrameRequester, ) -> PreparedConversation { self.conversation_frame.inset = conversation_inset; - self.conversation_frame.side_pane = false; + self.conversation_frame.side_pane = None; let area = self.conversation_area(viewport); if let Some((previous, reflowed)) = self.chat_widget.resize_owned_conversation(area.width) { self.transcript_view @@ -347,7 +363,6 @@ impl App { .min(area.height); let options = |interacting| ComposerRenderOptions { max_height: Some(max_composer_height), - extend_composer_background_left: self.conversation_frame.side_pane && area.x > 0, overlay_top: area.top(), command_popup_placement: if interacting { CommandPopupPlacement::Hidden @@ -504,7 +519,7 @@ impl App { ) -> Result { if self.conversation_frame.behind_dialog.is_none() { self.conversation_frame.caller_images.clear(); - if self.conversation_frame.inset.is_some() && !self.conversation_frame.side_pane { + if self.conversation_frame.has_caller_inset() { self.conversation_frame.caller_images.extend( tui.retained_images .iter() @@ -534,7 +549,7 @@ impl App { return; }; let area = frame.area(); - if self.conversation_frame.side_pane { + if self.conversation_frame.side_pane.is_some() { let covered = dialog.area(area); frame .image_placements @@ -578,11 +593,11 @@ impl App { event: &TuiEvent, ) -> Result { let has_modal = self.chat_widget.has_active_modal(); + let is_vertical_wheel = matches!(event, TuiEvent::Mouse(mouse) + if matches!(mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown)); let modal_transcript_mouse = has_modal - && matches!(event, TuiEvent::Mouse(mouse) - if self.chat_widget.centered_dialog().is_none() - || matches!(mouse.kind, crossterm::event::MouseEventKind::ScrollUp - | crossterm::event::MouseEventKind::ScrollDown)); + && matches!(event, TuiEvent::Mouse(_)) + && (self.chat_widget.centered_dialog().is_none() || is_vertical_wheel); let modal_transcript_draw = has_modal && self.chat_widget.centered_dialog().is_none() && matches!(event, TuiEvent::Draw); @@ -635,6 +650,28 @@ impl App { if mouse.kind != crossterm::event::MouseEventKind::Moved { self.refresh_owned_transcript(tui)?; } + if (is_vertical_wheel || mouse.kind == MouseEventKind::Down(MouseButton::Right)) + && self.chat_widget.no_modal_or_popup_active() + && let Some(panes) = &self.conversation_frame.side_pane + && panes.passive.contains((mouse.column, mouse.row).into()) + { + let id = panes.passive_thread; + let handled = self.handle_passive_transcript_event(tui, id, event); + if mouse.kind == MouseEventKind::ScrollUp + && app_server.has_older_history(id) + && self + .side_companions + .get_mut(&id) + .is_some_and(|pane| pane.view.needs_history(&pane.cells)) + && self.request_older_history_page(app_server, id) + && let Some(pane) = self.side_companions.get_mut(&id) + { + pane.view.history = TranscriptHistoryState::LoadingOlder; + } + if handled || is_vertical_wheel { + return Ok(true); + } + } if self.chat_widget.no_modal_or_popup_active() && self .chat_widget @@ -657,11 +694,7 @@ impl App { } if composer_ready && self.chat_widget.handle_composer_mouse(*mouse) { self.handle_composer_selection_release(tui, *mouse); - if !matches!( - mouse.kind, - crossterm::event::MouseEventKind::ScrollUp - | crossterm::event::MouseEventKind::ScrollDown - ) { + if !is_vertical_wheel { self.transcript_view.end_selection(&self.transcript_cells); self.transcript_view.end_select_mode(); self.transcript_view.cancel_search(); diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 1ac530d82b..e8d5ee9cee 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -1715,7 +1715,7 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul Ok(()) } -fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent { +pub(in crate::app) fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent { TuiEvent::Mouse(MouseEvent { kind, column, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index cc89a77464..ed524d507d 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -209,13 +209,6 @@ impl SideParentStatusChange { } } -/// Transcript state retained while the other conversation is active. -pub(super) struct SideCompanion { - pub(super) display: crate::app_event::CapturedSideDisplay, - pub(super) cells: Vec>, - pub(super) view: crate::transcript_view::TranscriptView, -} - #[derive(Clone, Debug)] pub(super) struct SideThreadState { /// Thread to return to when the current side conversation is dismissed. diff --git a/codex-rs/tui/src/app/side_companion.rs b/codex-rs/tui/src/app/side_companion.rs new file mode 100644 index 0000000000..d8e492d4d4 --- /dev/null +++ b/codex-rs/tui/src/app/side_companion.rs @@ -0,0 +1,283 @@ +//! The unfocused pane projects assistant replies and turn status while the thread event +//! store continues to own replay. + +use super::ThreadEventStore; +use crate::app_event::CapturedSideDisplay; +use crate::app_event::CapturedSideWork; +use crate::git_action_directives::parse_assistant_markdown; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::FinalMessageSeparator; +use crate::history_cell::HistoryCell; +use crate::transcript_view::TranscriptView; +use chrono::Local; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItemPayload; +use codex_app_server_protocol::TurnStatus; +use codex_protocol::items::AsyncUserInputQuestion; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +pub(super) struct SideCompanion { + pub(super) display: CapturedSideDisplay, + pub(super) cells: Vec>, + pub(super) view: TranscriptView, + pub(super) answer: Option, + pub(super) parked_tail: usize, +} + +pub(super) struct SideAnswer { + turn_id: String, + item_id: String, + index: usize, + pending: String, +} + +impl SideAnswer { + pub(super) fn new((turn_id, item_id): (String, String), index: usize) -> Self { + Self { + turn_id, + item_id, + index, + pending: String::new(), + } + } + + fn matches(&self, turn_id: &str, item_id: &str) -> bool { + self.turn_id == turn_id && self.item_id == item_id + } +} + +impl SideCompanion { + pub(super) fn apply_notification( + &mut self, + notification: &ServerNotification, + store: &ThreadEventStore, + new_status: impl FnOnce() -> crate::status_indicator_widget::StatusIndicatorWidget, + ) -> bool { + match notification { + ServerNotification::TurnStarted(event) + if self + .display + .work + .as_ref() + .is_none_or(|work| work.turn_id != event.turn.id) => + { + self.display.work = Some(CapturedSideWork::new( + event.turn.id.clone(), + new_status(), + Duration::ZERO, + )); + } + ServerNotification::AgentMessageDelta(delta) + if !delta.delta.is_empty() && !store.is_realtime_delegated_turn(&delta.turn_id) => + { + if self + .answer + .as_ref() + .is_some_and(|answer| !answer.matches(&delta.turn_id, &delta.item_id)) + { + self.flush_answer(); + self.answer = None; + } + let answer = self.answer.get_or_insert_with(|| { + SideAnswer::new( + (delta.turn_id.clone(), delta.item_id.clone()), + self.cells.len(), + ) + }); + answer.pending.push_str(&delta.delta); + } + ServerNotification::ItemCompleted(event) => { + let item = &event.item.payload; + if store.is_realtime_delegated_turn(&event.turn_id) + && crate::chatwidget::is_private_realtime_agent_item(item) + { + return false; + } + return self.finish_item(&event.turn_id, item); + } + ServerNotification::TurnCompleted(event) + if event.turn.status != TurnStatus::InProgress => + { + let turn = &event.turn; + if self.display.active.is_some() { + for item in turn.item_payloads() { + if matches!(item, ThreadItemPayload::CommandExecution { .. }) { + self.finish_item(&turn.id, item); + } + } + } + if turn.status == TurnStatus::Completed + && let Some((item, _)) = crate::chatwidget::final_agent_message( + turn, + store.is_realtime_delegated_turn(&turn.id), + ) + && crate::chatwidget::needs_agent_message( + self.display.last_completed_agent_message.as_ref(), + &turn.id, + item, + ) + { + self.finish_item(&turn.id, item); + } + if self + .answer + .as_ref() + .is_some_and(|answer| answer.turn_id == turn.id) + { + self.flush_answer(); + self.answer = None; + } + let work = self.display.work.take_if(|work| work.turn_id == turn.id); + if turn.status == TurnStatus::Completed + && (work.is_some() || store.active_turn_id() == Some(turn.id.as_str())) + && let Some(completion) = FinalMessageSeparator::from_turn_once( + turn, + &mut self.display.rendered_completion_turn_ids, + work.map(|work| work.timer.elapsed_at(Instant::now())), + Some(Local::now()), + ) + { + self.cells.push(Arc::new(completion)); + } + } + ServerNotification::ThreadClosed(_) => return self.display.work.take().is_some(), + _ => return false, + } + true + } + + /// History pages may arrive while a reply or command still owns a slot in the parked transcript. + pub(super) fn splice_older_cells( + &mut self, + range: std::ops::Range, + cells: Vec>, + ) { + let shift = |index: usize| { + if index >= range.end { + index - range.len() + cells.len() + } else { + index + } + }; + self.parked_tail = shift(self.parked_tail); + if let Some(answer) = &mut self.answer { + answer.index = shift(answer.index); + } + self.cells.splice(range, cells); + } + + fn finish_item(&mut self, turn: &str, item: &ThreadItemPayload) -> bool { + match item { + ThreadItemPayload::AgentMessage { + id, + text, + questions, + .. + } => { + let replaces = self + .answer + .as_ref() + .is_some_and(|answer| answer.matches(turn, id)); + if !replaces || text.is_empty() { + self.flush_answer(); + } + let finished = self.answer.take_if(|_| replaces); + if !text.is_empty() { + let index = finished.map_or(self.cells.len(), |answer| answer.index); + self.write_answer( + index, + text.clone(), + questions.as_deref().unwrap_or_default(), + ); + } + self.display.last_completed_agent_message = Some((turn.to_owned(), id.clone())); + } + ThreadItemPayload::CommandExecution { + id, + status, + exit_code, + source, + aggregated_output, + duration_ms, + .. + } => { + let Some(cell) = self.display.active.as_mut().and_then(|active| { + active + .as_any_mut() + .downcast_mut::() + }) else { + return false; + }; + if *status == codex_app_server_protocol::CommandExecutionStatus::InProgress + || !cell.complete_call( + id, + crate::exec_cell::CommandOutput::from_execution( + status.clone(), + *exit_code, + *source, + aggregated_output.clone(), + ), + Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64), + ) + { + return false; + } + if !cell.is_active() + && let Some(active) = self.display.active.take() + { + // Later answer deltas may already have appended a cell after the parked command. + self.cells.insert(self.parked_tail, Arc::from(active)); + if let Some(answer) = &mut self.answer + && answer.index >= self.parked_tail + { + answer.index += 1; + } + } + } + _ => return false, + } + true + } + + /// Commit pending deltas at paint time so parsing and wrapping run once per frame. + pub(super) fn flush_answer(&mut self) { + let Some(answer) = self + .answer + .as_mut() + .filter(|answer| !answer.pending.is_empty()) + else { + return; + }; + let index = answer.index; + let mut source = self + .cells + .get(index) + .and_then(|cell| cell.copy_source()) + .unwrap_or_default() + .to_owned(); + source.push_str(&answer.pending); + answer.pending.clear(); + self.write_answer(index, source, &[]); + } + + fn write_answer(&mut self, index: usize, source: String, questions: &[AsyncUserInputQuestion]) { + let cwd = &self.display.cwd; + let markdown = parse_assistant_markdown(&source, cwd.as_path()); + let cell: Arc = Arc::new(AgentMarkdownCell::from_parsed_message( + markdown.visible_markdown, + source, + cwd.as_path(), + self.display.visualizations.clone(), + questions, + )); + if index == self.cells.len() { + self.cells.push(cell); + } else { + self.view + .replace_range(&self.cells, index..index + 1, &cell); + self.cells[index] = cell; + } + } +} diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index efdd71305c..08aa70c2c0 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -1,29 +1,78 @@ -//! Layout and paint the two conversations. Only the displayed thread owns a live composer. +//! Lay out the two conversations and resize their divider. Only the displayed thread owns a live composer. use super::App; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; -use super::side::SideCompanion; +use super::side_companion::SideCompanion; use crate::bottom_pane::inset_footer_hint_area; use crate::custom_terminal::Frame; +use crate::legacy_core::config::edit::ConfigEdit; +use crate::legacy_core::config::edit::ConfigEditsBuilder; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; use codex_protocol::ThreadId; +use color_eyre::eyre::Result; +use crossterm::event::MouseButton::Left; +use crossterm::event::MouseEventKind::Down; +use crossterm::event::MouseEventKind::Drag; +use crossterm::event::MouseEventKind::Up; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Stylize; -use ratatui::text::Text; +use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::Widget; +use std::time::Duration; +const GAP: u16 = 1; + +#[derive(Clone, Copy)] +pub(super) struct DividerDrag { + passive_thread: ThreadId, + screen: Rect, + press: u16, + initial_width: u16, + width: u16, + guide_only: bool, +} + +impl DividerDrag { + pub(super) fn matches(&self, screen: Rect, pane: Option<&SidePaneLayout>) -> bool { + screen == self.screen && pane.is_some_and(|pane| pane.passive_thread == self.passive_thread) + } + + fn percent(&self) -> Option { + let total = u32::from(self.screen.width); + (self.width != self.initial_width) + .then(|| ((u32::from(self.width) * 100 + total / 2) / total).clamp(1, 99) as u8) + } +} + +fn sidebar_width(width: i32, total: u16) -> u16 { + width.clamp(36, i32::from(total.saturating_sub(64 + GAP).max(36))) as u16 +} + +fn preferred_width(total: u16, percent: Option) -> u16 { + let width = percent.map_or(total / 3, |p| { + ((u32::from(total) * u32::from(p) + 50) / 100) as u16 + }); + sidebar_width(i32::from(width), total) +} + +#[derive(PartialEq)] pub(super) struct SidePaneLayout { pub(super) focused: Rect, pub(super) passive_thread: ThreadId, - passive: Rect, + pub(super) passive: Rect, divider: u16, } /// A wide terminal can show a pair once the outgoing conversation has been captured. pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option { if area.width < 100 + || app.conversation_frame.has_caller_inset() || !app.side_retention_enabled(tui) || app .chat_widget @@ -47,10 +96,20 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option Option) { - let area = self.passive; - for y in area.y..area.bottom() { - frame.buffer[(self.divider, y)] - .set_symbol("│") - .set_style(ratatui::style::Style::default().dim()); +impl App { + pub(super) async fn handle_divider_event( + &mut self, + tui: &mut Tui, + event: &TuiEvent, + ) -> Result { + let mouse = match event { + TuiEvent::Mouse(mouse) if self.conversation_focus_available(tui) => mouse, + TuiEvent::Draw if self.conversation_focus_available(tui) => return Ok(false), + _ => { + if self.divider_drag.take().is_some() { + tui.frame_requester().schedule_frame(); + } + return Ok(false); + } + }; + if matches!(mouse.kind, Down(_)) && self.divider_drag.take().is_some() { + tui.frame_requester().schedule_frame(); } + match mouse.kind { + Down(Left) => { + let Some(painted) = &self.conversation_frame.side_pane else { + return Ok(false); + }; + if !painted.divider_contains(*mouse) { + return Ok(false); + } + let screen = tui.prepare_draw_size()?.into(); + if layout(self, tui, screen).as_ref() != Some(painted) { + return Ok(false); + } + let width = screen.right() - painted.divider - GAP; + self.divider_drag = Some(DividerDrag { + passive_thread: painted.passive_thread, + screen, + press: mouse.column, + initial_width: width, + width, + // Both panes share the divider. Reflowing either can move terminal-owned + // image pixels across it, so defer both layouts until release. + guide_only: !tui.retained_images.is_empty(), + }); + self.cancel_pending_key_chord(); + } + Drag(Left) | Up(Left) => { + let Some(mut drag) = self.divider_drag.take() else { + return Ok(false); + }; + let screen = tui.prepare_draw_size()?.into(); + if drag.matches(screen, layout(self, tui, screen).as_ref()) { + let width = i32::from(drag.initial_width) + i32::from(drag.press) + - i32::from(mouse.column); + drag.width = sidebar_width(width, screen.width); + if mouse.kind == Drag(Left) { + self.divider_drag = Some(drag); + } else if let Some(percent) = drag.percent() + && self.local_settings.tui.sidebar_width_percent != Some(percent) + { + let edit = ConfigEdit::SetPath { + segments: vec!["tui".into(), "sidebar_width_percent".into()], + value: i64::from(percent).into(), + }; + match ConfigEditsBuilder::for_config_path( + self.local_settings.user_config_path.as_path(), + ) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => self.local_settings.tui.sidebar_width_percent = Some(percent), + Err(err) => self + .chat_widget + .add_error_message(format!("Could not save sidebar width: {err}")), + } + } + } + } + _ => return Ok(false), + } + tui.frame_requester().schedule_frame(); + Ok(true) + } +} + +impl SidePaneLayout { + pub(super) fn divider_contains(&self, mouse: crossterm::event::MouseEvent) -> bool { + (self.divider..self.divider + GAP).contains(&mouse.column) + && (self.focused.y..self.focused.bottom()).contains(&mouse.row) + } + + /// Paint the divider, retained transcript and draft without claiming the terminal cursor. + pub(super) fn paint_companion( + &self, + companion: &mut SideCompanion, + frame: &mut Frame<'_>, + drag: Option, + divider_hovered: bool, + frames: &FrameRequester, + ) { + companion.flush_answer(); + let area = self.passive; // Keep the live composer's bottom hint row blank on the inactive side. let available = area.height.saturating_sub(1); let composer_height = available.min(3); let status_height = u16::from(companion.display.status.is_some() && available > composer_height); let above = available - composer_height - status_height; - let transcript = Rect { + let mut transcript = Rect { height: above.saturating_sub(1), ..area }; + if let Some(work) = &companion.display.work { + let row = work.row.with_timer(&work.timer); + let height = row.desired_height(area.width).min(transcript.height); + if height > 0 { + transcript.height -= height; + let status_area = Rect::new(area.x, transcript.bottom(), area.width, height); + row.render(status_area, frame.buffer); + frames.schedule_frame_in(Duration::from_secs(1)); + } + } companion.view.render_parked( transcript, frame.buffer, @@ -100,30 +261,25 @@ impl SidePaneLayout { height: composer_height, ..area }; - let gutter = u16::from(composer.x > self.divider); - frame.buffer.set_style( - Rect { - x: composer.x - gutter, - width: composer.width + gutter, - ..composer - }, - crate::style::quiet_workspace_style(), - ); + frame + .buffer + .set_style(composer, crate::style::quiet_workspace_style()); let inset = u16::from(composer_height > 1); - let draft = Text::from(format!("› {}", companion.display.draft)); - let lines = crate::wrapping::word_wrap_lines( - draft.lines, - crate::wrapping::RtOptions::new(usize::from(composer.width)) - .word_splitter(textwrap::WordSplitter::NoHyphenation), - ); - Text::from(lines).render( - Rect { - y: composer.y + inset, - height: composer_height - inset, - ..composer - }, - frame.buffer, + let text_area = Rect::new( + composer.x.saturating_add(/*rhs*/ 2), + composer.y + inset, + composer.width.saturating_sub(/*rhs*/ 2), + composer_height - inset, ); + if !text_area.is_empty() { + frame.buffer[(composer.x, text_area.y)].set_symbol("›"); + StatefulWidgetRef::render_ref( + &companion.display.draft.as_ref(), + text_area, + frame.buffer, + &mut Default::default(), + ); + } if let Some(status) = &companion.display.status { let footer = inset_footer_hint_area(Rect { y: composer.bottom(), @@ -136,5 +292,33 @@ impl SidePaneLayout { ) .render(footer, frame.buffer); } + + // Draw after both panes registered their images. Clearing a guide to the left + // of retained pixels can erase the rest of that terminal row. + let guide = drag + .filter(|drag| drag.guide_only) + .map_or(self.divider, |drag| { + drag.screen.right() + - drag.percent().map_or(drag.initial_width, |p| { + preferred_width(drag.screen.width, Some(p)) + }) + - GAP + }); + let style = if divider_hovered || drag.is_some() { + crate::style::accent_style() + } else { + ratatui::style::Style::default().dim() + }; + for y in area.y..area.bottom() { + let x = if guide != self.divider + && frame.image_placements.iter().any(|image| { + (image.area.y..image.area.bottom()).contains(&y) && guide < image.area.right() + }) { + self.divider + } else { + guide + }; + frame.buffer[(x, y)].set_symbol("│").set_style(style); + } } } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 1da74f0e60..24b7c712f4 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1061,6 +1061,8 @@ See the Codex keymap documentation for supported actions and examples." agents_overview, side_threads: HashMap::new(), side_companions: HashMap::new(), + divider_drag: None, + passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, active_thread_rx: None, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 53e3528840..07505d0f49 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -124,6 +124,8 @@ pub(crate) async fn make_test_app() -> Box { agents_overview: Default::default(), side_threads: HashMap::new(), side_companions: HashMap::new(), + divider_drag: None, + passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, active_thread_rx: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 64977b141e..f77d2e851b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -120,6 +120,7 @@ mod worktree_background_terminals_tests; use super::agent_navigation::AgentPickerThreadVisibility; use super::owned_transcript::tests::buffer_text; +use super::owned_transcript::tests::mouse; use super::test_support::enable_side_retention; use super::test_support::side_frame; use super::*; @@ -3610,12 +3611,12 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() || { side_frame(app, tui, size)?; let buffer = tui.terminal.previous_buffer(); - let divider = 62; + let divider = 63; let row = (0..size.height) - .find(|&y| buffer[(divider + 2, y)].symbol() == "›") + .find(|&y| buffer[(divider + 1, y)].symbol() == "›") .expect("right composer prompt"); let snapshot_row = |y| { - (divider..=divider + 2) + (divider - 1..=divider + 1) .map(|x| { let cell = &buffer[(x, y)]; format!("{:?} {:?}", cell.symbol(), cell.bg) @@ -3868,8 +3869,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() app.chat_widget.composer_text_with_pending(), "parent draft-!" ); + app.chat_widget.insert_str("\nsecond line\nlast line"); keys!(toggle); + // Mouse dispatch samples the backend size again after thread replay. + let terminal_at = |size| { + crate::tui::Terminal::with_screen_size_and_cursor_position_for_test( + ratatui::backend::CrosstermBackend::new(std::io::stdout()), + size, + Default::default(), + ) + }; + tui.terminal = terminal_at(size); + for (target, draft) in [ + (parent_thread_id, "parent draft-!\nsecond line\nlast line"), + (side_thread_id, "child draft!"), + ] { + let frame = side_frame(&mut app, &mut tui, size)?; + let (column, row) = frame + .lines() + .enumerate() + .find_map(|(row, line)| { + let (prefix, _) = line.split_once(draft.lines().next()?)?; + Some((prefix.chars().count() as u16, row as u16)) + }) + .expect("passive draft is visible"); + let left = MouseButton::Left; + for kind in [MouseEventKind::Down(left), MouseEventKind::Up(left)] { + let event = mouse(kind, column, row); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, event)).await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + } + assert_eq!(app.current_displayed_thread_id(), Some(target)); + keys!(KeyCode::Char('x').into(), KeyCode::End.into()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + format!("x{draft}") + ); + } + read_side_history(&mut app, &mut tui, "child")?; // The upload finishes before the app event loop can park the now-inactive child. app.app_event_tx.send(AppEvent::FeedbackSubmitted { @@ -3912,6 +3950,261 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() let frame = side_frame(&mut app, &mut tui, size)?; assert_eq!(frame.matches("unfinished answer").count(), 1, "{frame}"); + // A text drag reflows immediately. With retained graphics only the guide moves. + let wide = Size::new(/*width*/ 300, /*height*/ 18); + tui.terminal = terminal_at(wide); + keys!(toggle); + let path = app.local_settings.user_config_path.clone(); + let before = std::fs::read(&path).ok(); + let divider_at = |tui: &tui::Tui, y| { + (0..wide.width) + .find(|&x| tui.terminal.previous_buffer()[(x, y)].symbol() == "│") + .expect("painted divider") + }; + let mut previews = Vec::new(); + for with_image in [false, true] { + if with_image { + let image_path = app.config.codex_home.join("divider.png"); + image::RgbaImage::from_pixel(8, 16, image::Rgba([80, 30, 20, 255])) + .save(&image_path)?; + app.chat_widget.set_pet_image_support_for_tests( + crate::pets::PetImageSupport::Supported(crate::pets::ImageProtocol::Kitty), + ); + app.transcript_cells + .push(Arc::new(history_cell::new_image_generation_call( + "divider".into(), + "completed", + /*revised_prompt*/ None, + Some(image_path), + ))); + app.transcript_view.jump_to_latest(); + } + side_frame(&mut app, &mut tui, wide)?; + let divider = divider_at(&tui, 0); + for (x, highlighted) in [(divider - 1, false), (divider, true), (divider + 1, false)] { + boxed_future!(app.handle_tui_event( + &mut tui, + &mut app_server, + mouse(MouseEventKind::Moved, x, /*row*/ 4) + )) + .await?; + side_frame(&mut app, &mut tui, wide)?; + let style = tui.terminal.previous_buffer()[(divider, 4)].style(); + assert_eq!( + style.add_modifier.contains(ratatui::style::Modifier::BOLD), + highlighted + ); + } + let image = tui.retained_images.first().map(|p| p.area); + assert_eq!(image.is_some(), with_image); + keys!(prefix); + assert!(app.key_chord_matcher.is_pending()); + let left = MouseButton::Left; + for (kind, x) in [ + (MouseEventKind::Down(left), divider), + (MouseEventKind::Drag(left), divider - 1), + ] { + boxed_future!(app.handle_tui_event( + &mut tui, + &mut app_server, + mouse(kind, x, /*row*/ 4) + )) + .await?; + } + side_frame(&mut app, &mut tui, wide)?; + assert!(!app.key_chord_matcher.is_pending()); + let columns: Vec<_> = (0..wide.height).map(|y| divider_at(&tui, y)).collect(); + previews.push(*columns.iter().min().unwrap()); + assert_eq!(std::fs::read(&path).ok(), before); + if let Some(area) = image { + assert_eq!(tui.retained_images[0].area, area); + assert_eq!(columns[usize::from(area.y)], divider); + previews.push(columns[usize::from(area.y)]); + boxed_future!(app.handle_tui_event( + &mut tui, + &mut app_server, + mouse(MouseEventKind::Up(left), divider - 1, /*row*/ 4) + )) + .await?; + } else { + assert!(columns.iter().all(|x| *x == divider - 2)); + // A fresh transcript press cancels a drag whose release was lost. + for kind in [MouseEventKind::Down(left), MouseEventKind::Up(left)] { + boxed_future!(app.handle_tui_event( + &mut tui, + &mut app_server, + mouse(kind, wide.width - 3, /*row*/ 4) + )) + .await?; + } + assert_eq!(std::fs::read(&path).ok(), before); + side_frame(&mut app, &mut tui, wide)?; + assert_eq!(divider_at(&tui, 0), divider); + } + } + // Text preview, graphics guide, and the divider on the protected image row. + insta::assert_snapshot!(format!("{previews:?}"), @"[197, 197, 199]"); + side_frame(&mut app, &mut tui, wide)?; + assert_eq!(divider_at(&tui, 0), 197); + let saved: toml::Value = toml::from_str(&std::fs::read_to_string(&path)?)?; + assert_eq!(saved["tui"]["sidebar_width_percent"].as_integer(), Some(34)); + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + + keys!(prefix, main); + // Park a streaming parent through the normal focus shortcut, then finish it while parked. + let parent = parent_thread_id; + let turn = "live-parent"; + app.transcript_view.jump_to_latest(); + for event in [ + turn_started_notification(parent, turn), + agent_message_delta_notification(parent, turn, "reply", "Already visible "), + ] { + app.enqueue_thread_notification(parent, event).await?; + } + keys!(prefix, side); + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + let delta = agent_message_delta_notification(parent, turn, "reply", "while unfocused"); + app.enqueue_thread_notification(parent, delta).await?; + let streaming = side_frame(&mut app, &mut tui, size)?; + let item = ThreadItemPayload::AgentMessage { + id: "reply".into(), + text: "Already visible while unfocused.".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }; + // The transport may drop ItemCompleted and the last delta; TurnCompleted still carries the answer. + app.enqueue_thread_notification( + parent, + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: parent.to_string(), + turn: Turn { + completed_at: Some(0), + duration_ms: Some(1), + ..test_turn(turn, TurnStatus::Completed, vec![item]) + }, + }), + ) + .await?; + let completed = side_frame(&mut app, &mut tui, size)?; + // Normalize the changing timer while preserving any shortcut hint. + let visible_turn = |frame: &str| { + frame + .lines() + .filter_map(|row| { + let (main, _) = row.split_once('│')?; + let line = main.trim(); + if let Some((_, timer)) = line.split_once("Working (") { + let (_, suffix) = timer.split_once('s')?; + Some(format!("Working ([elapsed]{suffix}")) + } else if line.starts_with("Worked for ") { + line.split_once(" • ") + .map(|(elapsed, _)| elapsed.to_owned()) + } else { + line.contains("Already visible").then(|| line.to_owned()) + } + }) + .collect::>() + .join("\n") + }; + insta::assert_snapshot!(format!("{}\n---\n{}", visible_turn(&streaming), visible_turn(&completed)), @" + Already visible while unfocused + Working ([elapsed]) + --- + • Already visible while unfocused. + Worked for <1s + "); + + keys!(prefix, main); + let focused = side_frame(&mut app, &mut tui, size)?; + for frame in [&completed, &focused] { + let text = visible_turn(frame); + assert_eq!( + text.matches("Already visible while unfocused.").count(), + 1, + "{frame}" + ); + assert_eq!(text.matches("Worked for").count(), 1, "{frame}"); + } + + // A command already running at handoff belongs before replies received while parked. + let turn = "command-turn"; + let cwd = app.config.cwd.clone(); + let command = |status| ThreadItemPayload::CommandExecution { + id: "cmd".into(), + command: "echo sidebar-check".into(), + cwd: cwd.clone().into(), + status, + source: Default::default(), + command_actions: Vec::new(), + model_context: None, + sandbox_type: None, + process_id: None, + plugin_id: None, + script_path: None, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + use codex_app_server_protocol::CommandExecutionStatus; + for event in [ + turn_started_notification(parent, turn), + ServerNotification::ItemStarted(ItemStartedNotification::new( + parent.to_string(), + turn.into(), + command(CommandExecutionStatus::InProgress), + /*started_at_ms*/ 0, + )), + ] { + app.enqueue_thread_notification(parent, event).await?; + } + keys!(prefix, side); + let running = side_frame(&mut app, &mut tui, size)?; + assert!(running.contains("Running echo sidebar-check"), "{running}"); + app.enqueue_thread_notification( + parent, + agent_message_delta_notification(parent, turn, "after-command", "Command finished."), + ) + .await?; + side_frame(&mut app, &mut tui, size)?; + app.enqueue_thread_notification( + parent, + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + parent.to_string(), + turn.into(), + command(CommandExecutionStatus::Completed), + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), + ) + .await?; + let finished = side_frame(&mut app, &mut tui, size)?; + let (_, after_command) = finished + .split_once("Ran echo sidebar-check") + .unwrap_or_else(|| panic!("{finished}")); + assert!(after_command.contains("Command finished."), "{finished}"); + assert_eq!( + finished.matches("echo sidebar-check").count(), + 1, + "{finished}" + ); + + // Copy a retained range with the child composer still focused. + app.transcript_view.jump_to_latest(); + let parent = app.side_companions.get_mut(&parent_thread_id).unwrap(); + parent.view.jump_to_latest(); + assert!(parent.view.begin_select_mode(&parent.cells)); + for key in [KeyCode::Char('v'), KeyCode::Right] { + parent.view.handle_key(key.into(), &parent.cells); + } + assert!(parent.view.has_selection_range()); + keys!(KeyEvent::new(KeyCode::Insert, KeyModifiers::CONTROL)); + assert!(tui.clipboard.is_busy()); + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + app_server.shutdown().await?; Ok(()) } @@ -7187,6 +7480,8 @@ async fn make_test_app() -> Box { agents_overview: Default::default(), side_threads: HashMap::new(), side_companions: HashMap::new(), + divider_drag: None, + passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, active_thread_rx: None, @@ -7319,6 +7614,8 @@ pub(super) async fn make_test_app_with_channels() -> ( agents_overview: Default::default(), side_threads: HashMap::new(), side_companions: HashMap::new(), + divider_drag: None, + passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, active_thread_rx: None, diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs index 84a0c3ff47..1b5eb683df 100644 --- a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -124,7 +124,7 @@ pub(super) async fn completed_history_app( } #[tokio::test] -async fn side_replay_retries_an_older_find_page_discarded_while_inactive() -> Result<()> { +async fn older_page_updates_the_parked_parent_without_moving_focus() -> Result<()> { let (mut app, _codex_home, thread_id) = completed_history_app(&["Oldest", "Newest"]).await?; let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; let started = app_server @@ -135,8 +135,7 @@ async fn side_replay_retries_an_older_find_page_discarded_while_inactive() -> Re crate::app_server_session::ResumeModelSettings::RestoreFromThread, ) .await?; - let session = started.session; - app.enqueue_primary_thread_session(session.clone(), started.turns) + app.enqueue_primary_thread_session(started.session, started.turns) .await?; let (event_tx, mut events) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); @@ -147,14 +146,6 @@ async fn side_replay_retries_an_older_find_page_discarded_while_inactive() -> Re app.scrollback_has_older_history = app_server.has_older_history(thread_id); app.transcript_cells = vec![plain_line_cell("Newest answer")]; app.transcript_view.history = TranscriptHistoryState::Partial; - app.transcript_view.begin_search(); - app.transcript_view.paste_search("Oldest answer"); - while app.transcript_view.advance_search(&app.transcript_cells) {} - app.request_owned_history(&mut tui, &mut app_server); - let page = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) - .await? - .expect("first page requested by Find"); - let child = ThreadId::new(); app.side_threads .insert(child, SideThreadState::new(thread_id)); @@ -172,25 +163,32 @@ async fn side_replay_retries_an_older_find_page_discarded_while_inactive() -> Re .await?; app.chat_widget .handle_side_thread_session(test_thread_session(child, app.config.cwd.to_path_buf())); + app.active_thread_id = Some(child); + let size = Size::new(/*width*/ 120, /*height*/ 18); + tui.terminal = crate::tui::Terminal::with_screen_size_and_cursor_position_for_test( + ratatui::backend::CrosstermBackend::new(std::io::stdout()), + size, + Default::default(), + ); + side_frame(&mut app, &mut tui, size)?; + let wheel = mouse(MouseEventKind::ScrollUp, /*column*/ 4, /*row*/ 3); + assert!(app.handle_owned_transcript_event(&mut tui, &mut app_server, &wheel)?); + let page = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("wheel on the parent requests its older page"); boxed_future!(app.handle_event(&mut tui, &mut app_server, page)).await?; - app.chat_widget.handle_thread_session(session); - app.transcript_cells = vec![plain_line_cell("Newest answer")]; - app.pending_thread_switch_resets = 1; - boxed_future!(app.handle_event( - &mut tui, - &mut app_server, - AppEvent::SideReplayFinished(thread_id) - )) - .await?; - app.request_owned_history(&mut tui, &mut app_server); - let retry = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) - .await? - .expect("page retried by Find"); - boxed_future!(app.handle_event(&mut tui, &mut app_server, retry)).await?; - - let frame = side_frame(&mut app, &mut tui, Size::new(/*width*/ 100, /*height*/ 16))?; - assert!(frame.contains("Oldest answer"), "{frame}"); + assert_eq!(app.chat_widget.thread_id(), Some(child)); + let pane = &app.side_companions[&thread_id]; + let text = pane + .cells + .iter() + .flat_map(|cell| cell.display_lines(/*width*/ 100)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(text.contains("Oldest answer"), "{text}"); + assert_eq!(pane.view.history, TranscriptHistoryState::Complete); app_server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index e132d7b247..8f4b24cffa 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -193,6 +193,17 @@ impl ThreadEventStore { self.push_notification_inner(Cow::Borrowed(notification)); } + pub(super) fn is_realtime_delegated_turn(&self, turn_id: &str) -> bool { + self.delegated_turns.iter().any(|id| id == turn_id) + || self.turns.iter().any(|turn| { + turn.id == turn_id + && turn + .items + .iter() + .any(|entry| is_voice_handoff_item(&entry.payload)) + }) + } + fn push_notification_inner(&mut self, notification: Cow<'_, ServerNotification>) { let user_item = match notification.as_ref() { ServerNotification::ItemStarted(n) => Some((&n.turn_id, &n.item.payload)), @@ -262,15 +273,7 @@ impl ThreadEventStore { }, .. }, - ) if !self.delegated_turns.contains(&started.turn_id) - && !self.turns.iter().any(|turn| { - turn.id == started.turn_id - && turn - .items - .iter() - .any(|entry| is_voice_handoff_item(&entry.payload)) - }) => - { + ) if !self.is_realtime_delegated_turn(&started.turn_id) => { self.active_reasoning_item = Some(codex_app_server_protocol::ItemStartedNotification::new( started.thread_id.clone(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 016b1e9900..91c22cabff 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1197,6 +1197,13 @@ impl App { { self.retain_inactive_realtime_transcript(thread_id, ¬ification); } + if let Some(companion) = self.side_companions.get_mut(&thread_id) + && companion.apply_notification(¬ification, &guard, || { + self.chat_widget.new_status_indicator() + }) + { + self.chat_widget.request_redraw(); + } // Status traffic alone does not change the displayed transcript. guard.replay_may_have_output |= !matches!( ¬ification, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 5c0cf72fb3..0938adbf41 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use std::time::Duration; use tokio_util::sync::CancellationToken; use crate::inline_visualization::InlineVisualizationContext; @@ -296,17 +297,50 @@ pub(crate) struct AgentPickerThreadRefresh { /// Source-backed replacement for the outgoing widget's width-bound streaming rows. #[derive(Debug)] pub(crate) enum CapturedSideStream { - Answer(Arc), + Answer { + cell: Arc, + item: Option<(String, String)>, + }, Plan(Arc), } /// Presentation retained alongside the transcript while its composer is inactive. #[derive(Debug)] pub(crate) struct CapturedSideDisplay { + pub(crate) rendered_completion_turn_ids: std::collections::HashSet, pub(crate) active: Option>, pub(crate) auxiliary: Vec, - pub(crate) draft: String, + pub(crate) draft: Box, pub(crate) status: Option, + pub(crate) work: Option, + pub(crate) last_completed_agent_message: Option<(String, String)>, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) visualizations: Option, +} + +/// The inactive composer keeps counting from the time already spent in the active pane. +#[derive(Debug)] +pub(crate) struct CapturedSideWork { + pub(crate) turn_id: String, + pub(crate) row: Box, + pub(crate) timer: crate::status_indicator_widget::StatusTimer, +} + +impl CapturedSideWork { + pub(crate) fn new( + turn_id: String, + mut row: crate::status_indicator_widget::StatusIndicatorWidget, + elapsed: Duration, + ) -> Self { + let mut timer = crate::status_indicator_widget::StatusTimer::default(); + timer.reset(elapsed); + row.set_interrupt_hint_visible(/*visible*/ false); + Self { + turn_id, + row: Box::new(row), + timer, + } + } } #[allow(clippy::large_enum_variant)] @@ -669,6 +703,14 @@ pub(crate) enum AppEvent { /// Release input and drawing after the destination snapshot's queued history edits. SideReplayFinished(ThreadId), + /// Deliver the original pointer press after the target conversation's replay finishes. + SidePanePressAfterReplay { + draft_cursor: Option, + thread_id: ThreadId, + area: ratatui::layout::Rect, + press: crossterm::event::MouseEvent, + }, + /// Clear history queued by the previous thread before the new thread's replay events. ResetTranscriptForThreadSwitch, /// Reset queued history while keeping the startup draft visible until the next frame. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d23ccfcff7..681dcef543 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4933,8 +4933,7 @@ impl ChatComposer { selection.register(footer_rect.intersection(buf.area), changed); } let style = user_message_style(); - let background = composer_layout::background_rect(composer_rect, buf.area, options); - Block::default().style(style).render(background, buf); + Block::default().style(style).render(composer_rect, buf); if let Some(previews) = options.image_previews { previews.render( thumbnails, 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 a5199f78dd..d687ed06a8 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 @@ -8,19 +8,6 @@ use crate::tui::FrameRequester; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -pub(super) fn background_rect( - composer: Rect, - buffer: Rect, - options: ComposerRenderOptions<'_>, -) -> Rect { - let gutter = u16::from(options.extend_composer_background_left && composer.x > buffer.x); - Rect { - x: composer.x - gutter, - width: composer.width + gutter, - ..composer - } -} - impl ChatComposer { pub(crate) fn set_voice_strip( &mut self, 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 3d74ce3fa2..85ba7e945e 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 @@ -72,8 +72,6 @@ pub(crate) enum CommandPopupPlacement { pub(crate) struct ComposerRenderOptions<'a> { /// Fullscreen limits the composer independently of activity and modal views. pub(crate) max_height: Option, - /// Fill the empty cell next to the right pane's divider without moving the input. - pub(crate) extend_composer_background_left: bool, pub(crate) image_previews: Option<&'a crate::transcript_image::composer::ComposerImages>, pub(crate) rendered_selection: Option<&'a std::cell::RefCell>, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5f5e90dd14..a8a0a7b686 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1450,25 +1450,26 @@ impl BottomPane { pub(crate) fn ensure_status_indicator(&mut self) { if self.status.is_none() { - self.status.get_or_insert_with(|| { - StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - self.animations_enabled, - self.effects, - ) - }); - if let Some(status) = self.status.as_mut() { - status.set_interrupt_binding( - self.keymap - .primary_hint(KeymapContext::Chat, "interrupt_turn"), - ); - } + self.status = Some(self.new_status_indicator()); self.sync_status_inline_message(); self.request_redraw(); } } + pub(crate) fn new_status_indicator(&self) -> StatusIndicatorWidget { + let mut status = StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + self.effects, + ); + status.set_interrupt_binding( + self.keymap + .primary_hint(KeymapContext::Chat, "interrupt_turn"), + ); + status + } + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(visible); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 773825c487..cb44112c3f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -345,6 +345,8 @@ mod luna_reserve_return; mod security_setup; pub(crate) use backend_banners::AutomaticModelSwitchReason; mod protocol; +pub(crate) use protocol::final_agent_message; +pub(crate) use protocol::needs_agent_message; mod protocol_requests; mod rate_limits; mod usage_notice; @@ -1454,7 +1456,7 @@ impl ChatWidget { self.bottom_pane.show_shutdown_in_progress(); } - fn request_redraw(&mut self) { + pub(crate) fn request_redraw(&self) { self.frame_requester.schedule_frame(); } @@ -2087,6 +2089,12 @@ impl ChatWidget { &self.config } + pub(crate) fn new_status_indicator( + &self, + ) -> crate::status_indicator_widget::StatusIndicatorWidget { + self.bottom_pane.new_status_indicator() + } + pub(crate) fn status_line_text(&self) -> Option { self.status_line_content() .cloned() diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index 2e3840e55a..68455112a8 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -399,13 +399,6 @@ impl ChatWidget { .map(codex_app_server_protocol::CommandAction::into_core) .collect(); let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64); - let exit_code = if status == codex_app_server_protocol::CommandExecutionStatus::Completed { - exit_code.unwrap_or_default() - } else { - exit_code.filter(|code| *code != 0).unwrap_or(1) - }; - let aggregated_output = aggregated_output.unwrap_or_default(); - let running = self.running_commands.remove(&id); if self.suppressed_exec_calls.remove(&id) { return; @@ -415,8 +408,6 @@ impl ChatWidget { None => (event_command, event_parsed, source), }; let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); - let is_unified_exec_interaction = - matches!(source, ExecCommandSource::UnifiedExecInteraction); let is_user_shell = source == ExecCommandSource::UserShell; // Completion-only replay has no begin event to join adjacent exploration. Extend only // a finished, compatible group; an unrelated running group must retain orphan routing. @@ -458,11 +449,7 @@ impl ChatWidget { None => ExecEndTarget::NewCell, }; - let output = if is_unified_exec_interaction { - CommandOutput::new(exit_code, String::new()) - } else { - CommandOutput::new(exit_code, aggregated_output) - }; + let output = CommandOutput::from_execution(status, exit_code, source, aggregated_output); match end_target { ExecEndTarget::ActiveTracked => { diff --git a/codex-rs/tui/src/chatwidget/completion.rs b/codex-rs/tui/src/chatwidget/completion.rs index 3b59e3066e..7088acf7b6 100644 --- a/codex-rs/tui/src/chatwidget/completion.rs +++ b/codex-rs/tui/src/chatwidget/completion.rs @@ -9,46 +9,12 @@ impl ChatWidget { turn: &Turn, replay_kind: Option, ) -> Option { - if self - .turn_lifecycle - .rendered_completion_turn_ids - .contains(&turn.id) - { - return None; - } - let elapsed_seconds = turn - .duration_ms - .and_then(|duration| u64::try_from(duration).ok()) - .map(|duration| duration / 1_000) - .or_else(|| { - if replay_kind.is_none() { - self.bottom_pane - .status_elapsed() - .map(|elapsed| elapsed.as_secs()) - } else { - None - } - }); - let completed_at = turn - .completed_at - .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, /*nsecs*/ 0)) - .map(|timestamp| timestamp.with_timezone(&Local)) - .or_else(|| replay_kind.is_none().then(Local::now)); - if completed_at.is_none() && elapsed_seconds.is_none() { - return None; - } - self.turn_lifecycle - .rendered_completion_turn_ids - .insert(turn.id.clone()); - let cell = history_cell::FinalMessageSeparator::new( - elapsed_seconds, - /*runtime_metrics*/ None, - ); - Some(match completed_at { - Some(completed_at) => { - cell.with_completed_at(completed_at, crate::clock_format::ClockFormat::system()) - } - None => cell, - }) + let live = replay_kind.is_none(); + history_cell::FinalMessageSeparator::from_turn_once( + turn, + &mut self.turn_lifecycle.rendered_completion_turn_ids, + self.bottom_pane.status_elapsed().filter(|_| live), + live.then(Local::now), + ) } } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index a3f261616b..e203af6542 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -51,6 +51,10 @@ impl ChatWidget { .copy_composer_selection_on_release(event, selection_copy, copy) } + pub(crate) fn set_composer_cursor(&mut self, cursor: usize) { + self.bottom_pane.set_composer_cursor(cursor); + } + pub(crate) fn handle_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { self.bottom_pane.handle_composer_mouse(event) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 17b23819bf..394461e3a8 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -459,33 +459,16 @@ impl ChatWidget { self.thread_usage.replaying_turn_completion = replay_kind.is_some(); match notification.turn.status { TurnStatus::Completed => { - let last_agent_message = - notification - .turn - .item_payloads() - .rev() - .find_map(|item| match item { - ThreadItemPayload::AgentMessage { - id, - text, - phase: Some(MessagePhase::FinalAnswer) | None, - .. - } if !self - .is_realtime_delegated_reasoning_turn(¬ification.turn.id) - || !realtime::is_private_realtime_agent_item(item) => - { - Some((item.clone(), id.clone(), text.clone())) - } - _ => None, - }); - if let Some((item, id, _)) = &last_agent_message - && self - .transcript - .last_completed_agent_message - .as_ref() - .is_none_or(|(turn_id, item_id)| { - turn_id != ¬ification.turn.id || item_id != id - }) + let last_agent_message = final_agent_message( + ¬ification.turn, + self.is_realtime_delegated_reasoning_turn(¬ification.turn.id), + ); + if let Some((item, _)) = last_agent_message + && needs_agent_message( + self.transcript.last_completed_agent_message.as_ref(), + ¬ification.turn.id, + item, + ) { self.handle_thread_item( item.clone(), @@ -495,7 +478,7 @@ impl ChatWidget { ); } if replay_kind.is_none() - && let Some((item, _, _)) = &last_agent_message + && let Some((item, _)) = last_agent_message { self.speak_completed_realtime_delegation(¬ification.turn.id, item); } @@ -505,7 +488,7 @@ impl ChatWidget { self.last_non_retry_error = None; let completion = self.completion_cell(¬ification.turn, replay_kind); self.on_task_complete( - last_agent_message.map(|(_, _, text)| text), + last_agent_message.map(|(_, text)| text.to_owned()), completion, replay_kind.is_some(), ); @@ -719,3 +702,28 @@ impl ChatWidget { } } } + +/// Select the final reply that can be displayed when streaming events were missed. +pub(crate) fn final_agent_message( + turn: &Turn, + realtime_delegated: bool, +) -> Option<(&ThreadItemPayload, &str)> { + turn.item_payloads().rev().find_map(|item| match item { + ThreadItemPayload::AgentMessage { + text, + phase: Some(MessagePhase::FinalAnswer) | None, + .. + } if !realtime_delegated || !realtime::is_private_realtime_agent_item(item) => { + Some((item, text.as_str())) + } + _ => None, + }) +} + +pub(crate) fn needs_agent_message( + last: Option<&(String, String)>, + turn: &str, + item: &ThreadItemPayload, +) -> bool { + last.is_none_or(|(turn_id, item_id)| turn_id != turn || item_id != item.id()) +} diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index acbd7defc8..7bab05e974 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -6,6 +6,7 @@ use super::*; use crate::app_event::CapturedSideDisplay; use crate::app_event::CapturedSideStream; +use crate::app_event::CapturedSideWork; use crate::markdown_render::ListSpacing; fn streamed_message_matches_completed(streamed: &str, completed: &str) -> bool { @@ -210,22 +211,23 @@ impl ChatWidget { width: u16, ) -> (Option, CapturedSideDisplay) { self.flush_completed_tool_activity(); + let visualizations = self.thread_id.and_then(|id| { + crate::inline_visualization::InlineVisualizationContext::from_config(&self.config, id) + }); let stream = if let Some(controller) = &self.stream_controller { let source = controller.source_snapshot(); let markdown = parse_assistant_markdown(&source, self.config.cwd.as_path()); - let context = self.thread_id.and_then(|id| { - crate::inline_visualization::InlineVisualizationContext::from_config( - &self.config, - id, - ) - }); - let cell = history_cell::AgentMarkdownCell::new_with_inline_visualizations( + let cell = history_cell::AgentMarkdownCell::from_parsed_message( markdown.visible_markdown, + source, &self.config.cwd, - context, - ) - .with_copy_source(Some(source)); - Some(CapturedSideStream::Answer(Arc::new(cell))) + visualizations.clone(), + &[], + ); + Some(CapturedSideStream::Answer { + cell: Arc::new(cell), + item: self.transcript.active_streamed_agent_message.clone(), + }) } else if self.plan_stream_controller.is_some() { Some(CapturedSideStream::Plan(Arc::new( history_cell::new_proposed_plan( @@ -240,7 +242,7 @@ impl ChatWidget { .transcript .take_active_cell() .filter(|cell| match &stream { - Some(CapturedSideStream::Answer(_)) => { + Some(CapturedSideStream::Answer { .. }) => { !cell.as_any().is::() } Some(CapturedSideStream::Plan(_)) => { @@ -249,22 +251,31 @@ impl ChatWidget { None => true, }); let display = CapturedSideDisplay { + rendered_completion_turn_ids: self.turn_lifecycle.rendered_completion_turn_ids.clone(), active, + last_completed_agent_message: self.transcript.last_completed_agent_message.clone(), auxiliary: self .active_cell_transcript_hyperlink_lines(width) .unwrap_or_default(), - draft: if self.bottom_pane.is_in_paste_burst() { - let draft = self.bottom_pane.composer_recovery_snapshot(); - crate::bottom_pane::ChatComposer::expand_pending_pastes( - &draft.text, - draft.text_elements, - &draft.pending_pastes, - ) - .0 - } else { - self.composer_text_with_pending() + draft: { + let snapshot = self.bottom_pane.composer_recovery_snapshot(); + let mut draft = Box::new(crate::bottom_pane::TextArea::new()); + draft.set_text_with_elements(&snapshot.text, &snapshot.text_elements); + draft }, status: self.status_line_text(), + work: self + .last_turn_id() + .filter(|_| self.is_agent_turn_running()) + .map(|turn_id| { + CapturedSideWork::new( + turn_id.to_owned(), + self.new_status_indicator(), + self.bottom_pane.status_elapsed().unwrap_or_default(), + ) + }), + cwd: self.config.cwd.clone(), + visualizations, }; (stream, display) } @@ -537,15 +548,13 @@ impl ChatWidget { thread_id, ) }); - self.add_to_history( - history_cell::AgentMarkdownCell::new_with_inline_visualizations( - parsed.visible_markdown.clone(), - self.config.cwd.as_path(), - context, - ) - .with_questions(questions) - .with_copy_source(Some(message.clone())), - ); + self.add_to_history(history_cell::AgentMarkdownCell::from_parsed_message( + parsed.visible_markdown.clone(), + message.clone(), + self.config.cwd.as_path(), + context, + questions, + )); self.handle_stream_finished(); self.request_redraw(); } else { diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index a99ea49095..241e682a49 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -27,6 +27,25 @@ pub(crate) struct CommandOutput { } impl CommandOutput { + pub(crate) fn from_execution( + status: codex_app_server_protocol::CommandExecutionStatus, + exit_code: Option, + source: ExecCommandSource, + output: Option, + ) -> Self { + let exit_code = if status == codex_app_server_protocol::CommandExecutionStatus::Completed { + exit_code.unwrap_or_default() + } else { + exit_code.filter(|code| *code != 0).unwrap_or(1) + }; + let output = if source == ExecCommandSource::UnifiedExecInteraction { + String::new() + } else { + output.unwrap_or_default() + }; + Self::new(exit_code, output) + } + pub(crate) fn new(exit_code: i32, aggregated_output: String) -> Self { Self { exit_code, diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index b7dbb72891..2ace714366 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -574,6 +574,18 @@ pub(crate) struct AgentMarkdownCell { } impl AgentMarkdownCell { + pub(crate) fn from_parsed_message( + visible_markdown: String, + source: String, + cwd: &Path, + context: Option, + questions: &[codex_protocol::items::AsyncUserInputQuestion], + ) -> Self { + Self::new_with_inline_visualizations(visible_markdown, cwd, context) + .with_questions(questions) + .with_copy_source(Some(source)) + } + pub(crate) fn with_copy_source(mut self, source: Option) -> Self { self.copy_source = source; self diff --git a/codex-rs/tui/src/history_cell/separators.rs b/codex-rs/tui/src/history_cell/separators.rs index 7c398870a9..b00386c78c 100644 --- a/codex-rs/tui/src/history_cell/separators.rs +++ b/codex-rs/tui/src/history_cell/separators.rs @@ -6,6 +6,8 @@ use chrono::DateTime; use chrono::Datelike; use chrono::Local; use chrono::NaiveDate; +use codex_app_server_protocol::Turn; +use std::collections::HashSet; /// Completion metadata shown after the assistant's final response. /// @@ -24,6 +26,35 @@ pub struct FinalMessageSeparator { clock_format: ClockFormat, } impl FinalMessageSeparator { + pub(crate) fn from_turn_once( + turn: &Turn, + rendered_turn_ids: &mut HashSet, + fallback_elapsed: Option, + fallback_completed_at: Option>, + ) -> Option { + let elapsed_seconds = turn + .duration_ms + .and_then(|duration| u64::try_from(duration).ok()) + .map(|duration| duration / 1_000) + .or_else(|| fallback_elapsed.map(|elapsed| elapsed.as_secs())); + let completed_at = turn + .completed_at + .and_then(|timestamp| DateTime::from_timestamp(timestamp, /*nsecs*/ 0)) + .map(|timestamp| timestamp.with_timezone(&Local)) + .or(fallback_completed_at); + if completed_at.is_none() && elapsed_seconds.is_none() { + return None; + } + if !rendered_turn_ids.insert(turn.id.clone()) { + return None; + } + let cell = Self::new(elapsed_seconds, /*runtime_metrics*/ None); + Some(match completed_at { + Some(completed_at) => cell.with_completed_at(completed_at, ClockFormat::system()), + None => cell, + }) + } + /// Creates completion metadata using the protocol turn duration when available. pub(crate) fn new( elapsed_seconds: Option, diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index a01cb9bbe7..bd64a8a266 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -77,6 +77,7 @@ impl LocalSettings { question_esc_back: config.tui_question_esc_back, raw_output_mode: config.tui_raw_output_mode, fullscreen_transcript: config.tui_fullscreen_transcript, + sidebar_width_percent: config.tui_sidebar_width_percent, mouse_scroll_speed: config.tui_mouse_scroll_speed, copy_on_select: config.tui_copy_on_select, copy_on_select_clear_selection: config.tui_copy_on_select_clear_selection, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__direction_restores_parent_find.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__direction_restores_parent_find.snap index 0bfa40e558..206f0f6793 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__direction_restores_parent_find.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__direction_restores_parent_find.snap @@ -2,4 +2,4 @@ source: tui/src/app/tests.rs expression: "frame.lines().find(|row| row.contains(\"Find:\")).unwrap()" --- - Find: parent-2x4 │ GPT-6.1-Sol default + Find: parent-2x4 │ GPT-6.1-Sol default diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap index 1b3820bc4e..8b8e00d9a2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap @@ -23,24 +23,24 @@ parent-29 New activity · esc latest 100 columns, reopened -parent-24 │ child-24 - │ -parent-25 │ child-25 - │ -parent-26 │ child-26 - │ -parent-27 │ child-27 - │ -parent-28 │ child-28 - │ -parent-29 │ child-29 - │ - │ - │ -› parent draft- │ › child draft! - │ - GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent - │ Side from main thread · ⌃/ to swit +parent-24 │child-24 + │ +parent-25 │child-25 + │ +parent-26 │child-26 + │ +parent-27 │child-27 + │ +parent-28 │child-28 + │ +parent-29 │child-29 + │ + │ + │ +› parent draft- │› child draft! + │ + GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent + │ Side from main thread · ⌃/ to swit 99 columns, reopened child-24 diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_composer_gutter.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_composer_gutter.snap index 9b2ccd7e69..55b1d42a6b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_composer_gutter.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_composer_gutter.snap @@ -3,9 +3,9 @@ source: tui/src/app/tests.rs expression: "format!(\"Inactive\\n{inactive}\\n\\nFocused\\n{focused}\")" --- Inactive -"│" Reset | " " Rgb(31, 31, 31) | "›" Rgb(31, 31, 31) -"│" Reset | " " Reset | " " Reset +" " Rgb(48, 48, 48) | "│" Reset | "›" Rgb(31, 31, 31) +" " Reset | "│" Reset | " " Reset Focused -"│" Reset | " " Rgb(48, 48, 48) | "›" Rgb(48, 48, 48) -"│" Reset | " " Reset | " " Reset +" " Rgb(31, 31, 31) | "│" Reset | "›" Rgb(48, 48, 48) +" " Reset | "│" Reset | " " Reset diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap index 43d690e352..dfd4e239e9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap @@ -2,20 +2,20 @@ source: tui/src/app/tests.rs expression: panel --- -parent-24 │ child-24 - │ -parent-25 │ child-25 - │ -parent-26 │ child-26 - │ -parent-27 │ child-27 - │ -parent-28 │ child-28 - │ -parent-29 │ child-29 - │ - New activity · ↓ Back to bottom · esc │ - │ -› parent draft │ › child draft! - │ - GPT-6.1-Sol default · Main [default] │ GPT-6.1-Sol default +parent-24 │child-24 + │ +parent-25 │child-25 + │ +parent-26 │child-26 + │ +parent-27 │child-27 + │ +parent-28 │child-28 + │ +parent-29 │child-29 + │ + New activity · ↓ Back to bottom · esc │ + │ +› parent draft │› child draft! + │ + GPT-6.1-Sol default · Main [default] │ GPT-6.1-Sol default diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 3cb54b2cf7..cdd4ce4f3f 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -52,6 +52,7 @@ pub(crate) enum StatusDetailsCapitalization { } /// Displays a single-line in-progress status with optional wrapped details. +#[derive(Clone, Debug)] pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 25c70c4e51..ad987a35e9 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -71,6 +71,7 @@ mod input_boundary; mod job_control; mod keyboard_modes; mod link_pointer; +pub(crate) use link_pointer::PointerShape; mod notification_preview; #[cfg(test)] #[path = "tui/owned_screen_tests.rs"] diff --git a/codex-rs/tui/src/tui/alternate_screen.rs b/codex-rs/tui/src/tui/alternate_screen.rs index 7c99c1c451..de129f9273 100644 --- a/codex-rs/tui/src/tui/alternate_screen.rs +++ b/codex-rs/tui/src/tui/alternate_screen.rs @@ -72,9 +72,13 @@ impl Command for EnablePointerCapture { } impl AlternateScreen { - pub(super) fn set_link_pointer(&self, writer: &mut impl Write, over_link: bool) -> Result<()> { + pub(super) fn set_pointer_shape( + &self, + writer: &mut impl Write, + shape: super::PointerShape, + ) -> Result<()> { if self.mouse_active.load(Ordering::Relaxed) { - self.link_pointer.update(writer, over_link) + self.link_pointer.update(writer, shape) } else { self.link_pointer .restore(writer, codex_terminal_detection::terminal_info().name) diff --git a/codex-rs/tui/src/tui/link_pointer.rs b/codex-rs/tui/src/tui/link_pointer.rs index b10ee5b87f..984b27f992 100644 --- a/codex-rs/tui/src/tui/link_pointer.rs +++ b/codex-rs/tui/src/tui/link_pointer.rs @@ -1,4 +1,4 @@ -//! Link hover owns the mouse shape only while Codex captures pointer input. +//! Link and divider hover own the mouse shape only while Codex captures pointer input. //! Remember motion across repaints, but discard coordinates across focus and geometry changes. //! Cleanup is independent of Tui so panic, suspend, and editor handoffs also restore the shape. //! Kitty releases its override; Ghostty retains its existing text-cursor fallback. @@ -14,9 +14,19 @@ use crossterm::event::MouseEventKind; use super::Tui; use super::TuiEvent; +#[derive(Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum PointerShape { + #[default] + Default = 1, + Link, + Resize, +} + #[derive(Default)] pub(crate) struct LinkHover { pub(crate) mouse: Option, + pub(crate) shape: PointerShape, } impl LinkHover { @@ -40,7 +50,7 @@ impl LinkHover { } } -// 0: untouched/restored, 1: captured arrow, 2: link hand, 3: uncertain after a write failure. +// Zero means untouched/restored. A failed write leaves the previous shape unknown. // Keep cleanup armed until flush succeeds. #[derive(Default)] pub(super) struct LinkPointer(AtomicU8); @@ -50,22 +60,26 @@ impl LinkPointer { Self(AtomicU8::new(/*v*/ 0)) } - pub(super) fn update(&self, writer: &mut impl Write, over_link: bool) -> std::io::Result<()> { + pub(super) fn update( + &self, + writer: &mut impl Write, + shape: PointerShape, + ) -> std::io::Result<()> { let previous = self.0.load(Ordering::Relaxed); - let next = if over_link { 2 } else { 1 }; - if previous == next || (previous == 0 && !over_link) { + let next = shape as u8; + if previous == next || (previous == 0 && shape == PointerShape::Default) { return Ok(()); } self.0.store(next, Ordering::Relaxed); let result = writer - .write_all(if over_link { - b"\x1b]22;pointer\x1b\\" - } else { - b"\x1b]22;default\x1b\\" + .write_all(match shape { + PointerShape::Default => b"\x1b]22;default\x1b\\", + PointerShape::Link => b"\x1b]22;pointer\x1b\\", + PointerShape::Resize => b"\x1b]22;ew-resize\x1b\\", }) .and_then(|()| writer.flush()); if result.is_err() { - self.0.store(/*val*/ 3, Ordering::Relaxed); + self.0.store(u8::MAX, Ordering::Relaxed); } result } @@ -91,7 +105,7 @@ impl LinkPointer { } impl Tui { - pub(crate) fn set_link_pointer(&mut self, over_link: bool) -> std::io::Result<()> { + pub(crate) fn set_pointer_shape(&mut self, shape: PointerShape) -> std::io::Result<()> { // OSC 22 names differ between terminals. Restrict CSS names to known implementations; // multiplexers may consume OSC 22 and do not promise pointer-shape passthrough. let info = codex_terminal_detection::terminal_info(); @@ -99,7 +113,7 @@ impl Tui { && matches!(info.name, TerminalName::Ghostty | TerminalName::Kitty) { super::alternate_screen::ALTERNATE_SCREEN - .set_link_pointer(self.terminal.backend_mut(), over_link)?; + .set_pointer_shape(self.terminal.backend_mut(), shape)?; } Ok(()) } diff --git a/codex-rs/tui/src/tui/link_pointer_tests.rs b/codex-rs/tui/src/tui/link_pointer_tests.rs index 2529747cf0..ff4dcc098d 100644 --- a/codex-rs/tui/src/tui/link_pointer_tests.rs +++ b/codex-rs/tui/src/tui/link_pointer_tests.rs @@ -1,4 +1,7 @@ use super::*; +use PointerShape::Default as Arrow; +use PointerShape::Link; +use PointerShape::Resize; use crossterm::event::KeyModifiers; use crossterm::event::MouseButton; use pretty_assertions::assert_eq; @@ -9,17 +12,17 @@ fn hover_transitions_are_deduplicated_and_restore_after_leaving_a_link() { for terminal in [TerminalName::Ghostty, TerminalName::Kitty] { let pointer = LinkPointer::default(); let mut output = Vec::new(); - for over_link in [false, true, true, false, false, true] { - pointer.update(&mut output, over_link).unwrap(); + for shape in [Arrow, Link, Link, Resize, Resize, Arrow, Arrow, Link] { + pointer.update(&mut output, shape).unwrap(); frames.push(String::from_utf8(std::mem::take(&mut output)).unwrap()); } pointer.restore(&mut output, terminal).unwrap(); frames.push(String::from_utf8(std::mem::take(&mut output)).unwrap()); pointer.restore(&mut output, terminal).unwrap(); // Re-entering capture off a link must preserve the restored terminal policy. - pointer.update(&mut output, /*over_link*/ false).unwrap(); + pointer.update(&mut output, Arrow).unwrap(); assert_eq!(output, Vec::::new()); - pointer.update(&mut output, /*over_link*/ true).unwrap(); + pointer.update(&mut output, Link).unwrap(); frames.push(String::from_utf8(output).unwrap()); } insta::assert_debug_snapshot!(frames, @r#" @@ -27,6 +30,8 @@ fn hover_transitions_are_deduplicated_and_restore_after_leaving_a_link() { "", "\u{1b}]22;pointer\u{1b}\\", "", + "\u{1b}]22;ew-resize\u{1b}\\", + "", "\u{1b}]22;default\u{1b}\\", "", "\u{1b}]22;pointer\u{1b}\\", @@ -35,6 +40,8 @@ fn hover_transitions_are_deduplicated_and_restore_after_leaving_a_link() { "", "\u{1b}]22;pointer\u{1b}\\", "", + "\u{1b}]22;ew-resize\u{1b}\\", + "", "\u{1b}]22;default\u{1b}\\", "", "\u{1b}]22;pointer\u{1b}\\", @@ -47,16 +54,14 @@ fn hover_transitions_are_deduplicated_and_restore_after_leaving_a_link() { #[test] #[cfg(unix)] fn capture_release_restores_pointer_even_after_moving_off_link() { - for over_link in [true, false] { + for shape in [Link, Resize, Arrow] { let screen = super::super::alternate_screen::AlternateScreen::default(); let mut output = Vec::new(); screen .configure_input(&mut output, /*capture_mouse*/ true) .unwrap(); - screen - .set_link_pointer(&mut output, /*over_link*/ true) - .unwrap(); - screen.set_link_pointer(&mut output, over_link).unwrap(); + screen.set_pointer_shape(&mut output, Link).unwrap(); + screen.set_pointer_shape(&mut output, shape).unwrap(); output.clear(); screen.release_input(&mut output).unwrap(); let expected = if codex_terminal_detection::terminal_info().name == TerminalName::Kitty { @@ -66,9 +71,7 @@ fn capture_release_restores_pointer_even_after_moving_off_link() { }; assert!(output.starts_with(expected)); output.clear(); - screen - .set_link_pointer(&mut output, /*over_link*/ true) - .unwrap(); + screen.set_pointer_shape(&mut output, Link).unwrap(); assert_eq!(output, Vec::::new()); } } @@ -89,9 +92,9 @@ fn failed_write_is_retried_and_cleanup_remains_armed() { (TerminalName::Kitty, b"\x1b]22;\x1b\\".as_slice()), ] { let pointer = LinkPointer::default(); - assert!(pointer.update(&mut Fails, /*over_link*/ true).is_err()); + assert!(pointer.update(&mut Fails, Link).is_err()); let mut output = Vec::new(); - pointer.update(&mut output, /*over_link*/ true).unwrap(); + pointer.update(&mut output, Link).unwrap(); assert_eq!(output, b"\x1b]22;pointer\x1b\\"); assert!(pointer.restore(&mut Fails, terminal).is_err()); output.clear();