mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
//! Conversation-local state for the application-owned conversation surface.
|
|
//!
|
|
//! The owned screen supports a fixed parent pane and an optional side pane. Focus controls where
|
|
//! user input goes, while a temporary dispatch slot lets asynchronous events update their origin
|
|
//! pane without changing focus.
|
|
|
|
use std::ops::Deref;
|
|
use std::ops::DerefMut;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use codex_protocol::ThreadId;
|
|
use crossterm::event::KeyCode;
|
|
use tokio::sync::mpsc;
|
|
|
|
use super::InitialHistoryReplayBuffer;
|
|
use super::owned_screen::OwnedScreen;
|
|
use super::owned_screen_resize::OwnedScreenLayout;
|
|
use super::owned_screen_resize::OwnedScreenSplitState;
|
|
use super::owned_screen_resize::PaneSplitPreference;
|
|
use super::thread_events::ThreadBufferedEvent;
|
|
use crate::app_event::ConversationOrigin;
|
|
use crate::app_event::PaneSlot;
|
|
use crate::chatwidget::ChatWidget;
|
|
use crate::file_search::FileSearchManager;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::key_hint;
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::streaming::StreamSurface;
|
|
use crate::transcript_reflow::TranscriptReflowState;
|
|
use crate::tui::MousePrimaryEvent;
|
|
|
|
pub(super) fn parent_pane_shortcut() -> KeyBinding {
|
|
key_hint::alt(KeyCode::Char('1'))
|
|
}
|
|
|
|
pub(super) fn side_pane_shortcut() -> KeyBinding {
|
|
key_hint::alt(KeyCode::Char('2'))
|
|
}
|
|
|
|
pub(super) struct ConversationPaneInit {
|
|
pub(super) chat_widget: ChatWidget,
|
|
pub(super) file_search: FileSearchManager,
|
|
pub(super) owned_screen: Option<OwnedScreen>,
|
|
}
|
|
|
|
pub(crate) struct ConversationPane {
|
|
slot: PaneSlot,
|
|
pub(super) chat_widget: ChatWidget,
|
|
pub(super) file_search: FileSearchManager,
|
|
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
|
pub(super) owned_screen: Option<OwnedScreen>,
|
|
pub(super) transcript_reflow: TranscriptReflowState,
|
|
pub(super) initial_history_replay_buffer: Option<InitialHistoryReplayBuffer>,
|
|
pub(super) commit_anim_running: Arc<AtomicBool>,
|
|
pub(super) active_thread_id: Option<ThreadId>,
|
|
pub(super) active_thread_rx: Option<mpsc::Receiver<ThreadBufferedEvent>>,
|
|
}
|
|
|
|
impl ConversationPane {
|
|
fn new(init: ConversationPaneInit, slot: PaneSlot) -> Result<Self, ConversationPaneInit> {
|
|
if init
|
|
.chat_widget
|
|
.conversation_origin()
|
|
.is_some_and(|origin| origin.pane != slot)
|
|
{
|
|
return Err(init);
|
|
}
|
|
|
|
let mut init = init;
|
|
let stream_surface = if init.owned_screen.is_some() {
|
|
StreamSurface::Retained
|
|
} else {
|
|
StreamSurface::Inline
|
|
};
|
|
init.chat_widget.set_stream_surface(stream_surface);
|
|
|
|
Ok(Self {
|
|
slot,
|
|
chat_widget: init.chat_widget,
|
|
file_search: init.file_search,
|
|
transcript_cells: Vec::new(),
|
|
owned_screen: init.owned_screen,
|
|
transcript_reflow: TranscriptReflowState::default(),
|
|
initial_history_replay_buffer: None,
|
|
commit_anim_running: Arc::new(AtomicBool::new(/*v*/ false)),
|
|
active_thread_id: None,
|
|
active_thread_rx: None,
|
|
})
|
|
}
|
|
|
|
pub(super) fn origin(&self) -> Option<ConversationOrigin> {
|
|
self.chat_widget
|
|
.conversation_origin()
|
|
.filter(|origin| origin.pane == self.slot)
|
|
}
|
|
|
|
fn thread_id(&self) -> Option<ThreadId> {
|
|
self.active_thread_id.or(self.chat_widget.thread_id())
|
|
}
|
|
|
|
pub(super) fn attach_thread(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
receiver: Option<mpsc::Receiver<ThreadBufferedEvent>>,
|
|
) {
|
|
self.active_thread_id = Some(thread_id);
|
|
self.active_thread_rx = receiver;
|
|
}
|
|
|
|
pub(super) fn take_thread_receiver(&mut self) -> Option<mpsc::Receiver<ThreadBufferedEvent>> {
|
|
self.active_thread_rx.take()
|
|
}
|
|
|
|
pub(super) fn clear_thread(&mut self) {
|
|
self.active_thread_id = None;
|
|
self.active_thread_rx = None;
|
|
}
|
|
}
|
|
|
|
impl Deref for ConversationPane {
|
|
type Target = ChatWidget;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.chat_widget
|
|
}
|
|
}
|
|
|
|
impl DerefMut for ConversationPane {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.chat_widget
|
|
}
|
|
}
|
|
|
|
pub(crate) struct ConversationPanes {
|
|
parent: ConversationPane,
|
|
side: Option<ConversationPane>,
|
|
focused: PaneSlot,
|
|
dispatch: Option<PaneSlot>,
|
|
owned_screen_split: OwnedScreenSplitState,
|
|
selection_clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
|
|
}
|
|
|
|
// Rendering and `/side` lifecycle consume the remaining APIs in the next stack layers.
|
|
#[allow(dead_code)]
|
|
impl ConversationPanes {
|
|
pub(super) fn new_parent(init: ConversationPaneInit) -> Result<Self, ConversationPaneInit> {
|
|
Ok(Self {
|
|
parent: ConversationPane::new(init, PaneSlot::Parent)?,
|
|
side: None,
|
|
focused: PaneSlot::Parent,
|
|
dispatch: None,
|
|
owned_screen_split: OwnedScreenSplitState::default(),
|
|
selection_clipboard_lease: None,
|
|
})
|
|
}
|
|
|
|
pub(super) fn focused_slot(&self) -> PaneSlot {
|
|
self.focused
|
|
}
|
|
|
|
pub(super) fn has_side(&self) -> bool {
|
|
self.side.is_some()
|
|
}
|
|
|
|
pub(super) fn focus(&mut self, slot: PaneSlot) -> bool {
|
|
if self.by_slot(slot).is_none() {
|
|
return false;
|
|
}
|
|
self.focused = slot;
|
|
self.for_each_installed_mut(|pane| {
|
|
pane.chat_widget
|
|
.set_terminal_title_output_enabled(pane.slot == slot);
|
|
});
|
|
if let Some(pane) = self.by_slot_mut(slot) {
|
|
pane.chat_widget.refresh_terminal_title();
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(super) fn install_side(
|
|
&mut self,
|
|
init: ConversationPaneInit,
|
|
) -> Result<Option<ConversationPane>, ConversationPaneInit> {
|
|
let mut pane = ConversationPane::new(init, PaneSlot::Side)?;
|
|
pane.chat_widget
|
|
.set_terminal_title_output_enabled(self.focused == PaneSlot::Side);
|
|
Ok(self.side.replace(pane))
|
|
}
|
|
|
|
pub(super) fn take_side(&mut self) -> Option<ConversationPane> {
|
|
self.owned_screen_split.clear_rendered_layout();
|
|
if self.focused == PaneSlot::Side {
|
|
self.focus(PaneSlot::Parent);
|
|
}
|
|
if self.dispatch == Some(PaneSlot::Side) {
|
|
self.dispatch = None;
|
|
}
|
|
if let Some(side) = &self.side {
|
|
side.commit_anim_running
|
|
.store(/*val*/ false, Ordering::Release);
|
|
}
|
|
self.side.take()
|
|
}
|
|
|
|
pub(super) fn owned_screen_split_preference(&self) -> PaneSplitPreference {
|
|
self.owned_screen_split.preference()
|
|
}
|
|
|
|
pub(super) fn record_owned_screen_layout(&mut self, layout: OwnedScreenLayout) {
|
|
self.owned_screen_split.record_layout(layout);
|
|
}
|
|
|
|
pub(super) fn handle_owned_screen_split_mouse(&mut self, event: MousePrimaryEvent) -> bool {
|
|
self.owned_screen_split.handle_mouse(event)
|
|
}
|
|
|
|
pub(super) fn owned_screen_split_is_dragging(&self) -> bool {
|
|
self.owned_screen_split.is_dragging()
|
|
}
|
|
|
|
pub(super) fn cancel_owned_screen_split_drag(&mut self) -> bool {
|
|
self.owned_screen_split.cancel_drag()
|
|
}
|
|
|
|
pub(super) fn retain_selection_clipboard_lease(
|
|
&mut self,
|
|
lease: Option<crate::clipboard_copy::ClipboardLease>,
|
|
) {
|
|
if lease.is_some() {
|
|
self.selection_clipboard_lease = lease;
|
|
}
|
|
}
|
|
|
|
pub(super) fn by_slot(&self, slot: PaneSlot) -> Option<&ConversationPane> {
|
|
match slot {
|
|
PaneSlot::Parent => Some(&self.parent),
|
|
PaneSlot::Side => self.side.as_ref(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn by_slot_mut(&mut self, slot: PaneSlot) -> Option<&mut ConversationPane> {
|
|
match slot {
|
|
PaneSlot::Parent => Some(&mut self.parent),
|
|
PaneSlot::Side => self.side.as_mut(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn by_origin(&self, origin: ConversationOrigin) -> Option<&ConversationPane> {
|
|
self.by_slot(origin.pane)
|
|
.filter(|pane| pane.origin() == Some(origin))
|
|
}
|
|
|
|
pub(super) fn by_thread_id(&self, thread_id: ThreadId) -> Option<&ConversationPane> {
|
|
if self.parent.thread_id() == Some(thread_id) {
|
|
return Some(&self.parent);
|
|
}
|
|
self.side
|
|
.as_ref()
|
|
.filter(|pane| pane.thread_id() == Some(thread_id))
|
|
}
|
|
|
|
pub(super) fn by_thread_id_mut(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Option<&mut ConversationPane> {
|
|
if self.parent.thread_id() == Some(thread_id) {
|
|
return Some(&mut self.parent);
|
|
}
|
|
self.side
|
|
.as_mut()
|
|
.filter(|pane| pane.thread_id() == Some(thread_id))
|
|
}
|
|
|
|
pub(super) fn contains_thread(&self, thread_id: ThreadId) -> bool {
|
|
self.by_thread_id(thread_id).is_some()
|
|
}
|
|
|
|
pub(super) fn installed_thread_ids(&self) -> Vec<ThreadId> {
|
|
let mut thread_ids = Vec::new();
|
|
for pane in std::iter::once(&self.parent).chain(self.side.iter()) {
|
|
if let Some(thread_id) = pane.thread_id()
|
|
&& !thread_ids.contains(&thread_id)
|
|
{
|
|
thread_ids.push(thread_id);
|
|
}
|
|
}
|
|
thread_ids
|
|
}
|
|
|
|
pub(super) fn for_each_installed_mut(&mut self, mut visit: impl FnMut(&mut ConversationPane)) {
|
|
visit(&mut self.parent);
|
|
if let Some(side) = &mut self.side {
|
|
visit(side);
|
|
}
|
|
}
|
|
|
|
pub(super) fn dispatch_to(&mut self, origin: ConversationOrigin) -> bool {
|
|
if self.by_origin(origin).is_none() {
|
|
return false;
|
|
}
|
|
self.dispatch = Some(origin.pane);
|
|
true
|
|
}
|
|
|
|
pub(super) fn clear_dispatch(&mut self) -> Option<PaneSlot> {
|
|
self.dispatch.take()
|
|
}
|
|
|
|
pub(super) fn finish_dispatch(&mut self, origin: ConversationOrigin) -> bool {
|
|
match self.clear_dispatch() {
|
|
Some(slot) => slot == origin.pane,
|
|
None => self.by_origin(origin).is_none(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn selected_mut(&mut self) -> &mut ConversationPane {
|
|
let slot = self.dispatch.unwrap_or(self.focused);
|
|
self.by_slot_mut(slot)
|
|
.expect("focused or dispatch pane must be installed")
|
|
}
|
|
|
|
pub(super) fn has_thread_event_receiver(&self) -> bool {
|
|
self.parent.active_thread_rx.is_some()
|
|
|| self
|
|
.side
|
|
.as_ref()
|
|
.is_some_and(|pane| pane.active_thread_rx.is_some())
|
|
}
|
|
|
|
/// Waits fairly for an event from either installed pane.
|
|
pub(super) async fn recv_thread_event(&mut self) -> (PaneSlot, Option<ThreadBufferedEvent>) {
|
|
let parent_rx = self.parent.active_thread_rx.as_mut();
|
|
let side_rx = self
|
|
.side
|
|
.as_mut()
|
|
.and_then(|pane| pane.active_thread_rx.as_mut());
|
|
|
|
match (parent_rx, side_rx) {
|
|
(Some(parent_rx), Some(side_rx)) => {
|
|
tokio::select! {
|
|
event = parent_rx.recv() => (PaneSlot::Parent, event),
|
|
event = side_rx.recv() => (PaneSlot::Side, event),
|
|
}
|
|
}
|
|
(Some(parent_rx), None) => (PaneSlot::Parent, parent_rx.recv().await),
|
|
(None, Some(side_rx)) => (PaneSlot::Side, side_rx.recv().await),
|
|
(None, None) => (self.focused, None),
|
|
}
|
|
}
|
|
|
|
fn selected(&self) -> &ConversationPane {
|
|
let slot = self.dispatch.unwrap_or(self.focused);
|
|
self.by_slot(slot)
|
|
.expect("focused or dispatch pane must be installed")
|
|
}
|
|
}
|
|
|
|
impl Deref for ConversationPanes {
|
|
type Target = ConversationPane;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
self.selected()
|
|
}
|
|
}
|
|
|
|
impl DerefMut for ConversationPanes {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
self.selected_mut()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "conversation_panes_tests.rs"]
|
|
mod tests;
|