diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 00000000..546a1927 --- /dev/null +++ b/.codespellignore @@ -0,0 +1 @@ +iTerm diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 51df5c70..5737a6bc 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -23,3 +23,5 @@ jobs: uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - name: Codespell uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2 + with: + ignore_words_file: .codespellignore diff --git a/codex-rs/README.md b/codex-rs/README.md index a8b5841d..2c95976c 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -329,3 +329,21 @@ Currently, `"vscode"` is the default, though Codex does not verify VS Code is in ### project_doc_max_bytes Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB. + +### tui + +Options that are specific to the TUI. + +```toml +[tui] +# This will make it so that Codex does not try to process mouse events, which +# means your Terminal's native drag-to-text to text selection and copy/paste +# should work. The tradeoff is that Codex will not receive any mouse events, so +# it will not be possible to use the mouse to scroll conversation history. +# +# Note that most terminals support holding down a modifier key when using the +# mouse to support text selection. For example, even if Codex mouse capture is +# enabled (i.e., this is set to `false`), you can still hold down alt while +# dragging the mouse to select text. +disable_mouse_capture = true # defaults to `false` +``` diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index fc56e85e..fd6356ab 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -88,6 +88,9 @@ pub struct Config { /// Optional URI-based file opener. If set, citations to files in the model /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, + + /// Collection of settings that are specific to the TUI. + pub tui: Tui, } /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. @@ -111,6 +114,23 @@ pub enum HistoryPersistence { None, } +/// Collection of settings that are specific to the TUI. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct Tui { + /// By default, mouse capture is enabled in the TUI so that it is possible + /// to scroll the conversation history with a mouse. This comes at the cost + /// of not being able to use the mouse to select text in the TUI. + /// (Most terminals support a modifier key to allow this. For example, + /// text selection works in iTerm if you hold down the `Option` key while + /// clicking and dragging.) + /// + /// Setting this option to `true` disables mouse capture, so scrolling with + /// the mouse is not possible, though the keyboard shortcuts e.g. `b` and + /// `space` still work. This allows the user to select text in the TUI + /// using the mouse without needing to hold down a modifier key. + pub disable_mouse_capture: bool, +} + #[derive(Deserialize, Debug, Copy, Clone, PartialEq)] pub enum UriBasedFileOpener { #[serde(rename = "vscode")] @@ -197,6 +217,9 @@ pub struct ConfigToml { /// Optional URI-based file opener. If set, citations to files in the model /// output will be hyperlinked using the specified URI scheme. pub file_opener: Option, + + /// Collection of settings that are specific to the TUI. + pub tui: Option, } impl ConfigToml { @@ -391,6 +414,7 @@ impl Config { codex_home, history, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), + tui: cfg.tui.unwrap_or_default(), }; Ok(config) } @@ -727,6 +751,7 @@ disable_response_storage = true codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, + tui: Tui::default(), }, o3_profile_config ); @@ -763,6 +788,7 @@ disable_response_storage = true codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, + tui: Tui::default(), }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -814,6 +840,7 @@ disable_response_storage = true codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, + tui: Tui::default(), }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 494e3804..bddd3871 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; +use crate::mouse_capture::MouseCapture; use crate::scroll_event_helper::ScrollEventHelper; use crate::slash_command::SlashCommand; use crate::tui; @@ -122,7 +123,11 @@ impl App<'_> { self.app_event_tx.clone() } - pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { + pub(crate) fn run( + &mut self, + terminal: &mut tui::Tui, + mouse_capture: &mut MouseCapture, + ) -> Result<()> { // Insert an event to trigger the first render. let app_event_tx = self.app_event_tx.clone(); app_event_tx.send(AppEvent::Redraw); @@ -176,6 +181,11 @@ impl App<'_> { SlashCommand::Clear => { self.chat_widget.clear_conversation_history(); } + SlashCommand::ToggleMouseMode => { + if let Err(e) = mouse_capture.toggle() { + tracing::error!("Failed to toggle mouse mode: {e}"); + } + } SlashCommand::Quit => { break; } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 419223a9..505a4bc6 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -17,6 +17,8 @@ use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; const MAX_POPUP_ROWS: usize = 5; +/// Ideally this is enough to show the longest command name. +const FIRST_COLUMN_WIDTH: u16 = 20; use ratatui::style::Modifier; @@ -176,15 +178,18 @@ impl WidgetRef for CommandPopup { use ratatui::layout::Constraint; - let table = Table::new(rows, [Constraint::Length(15), Constraint::Min(10)]) - .style(style) - .column_spacing(1) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .style(style), - ); + let table = Table::new( + rows, + [Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)], + ) + .style(style) + .column_spacing(1) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(style), + ); table.render(area, buf); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a6849f62..f4391785 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -27,6 +27,7 @@ mod git_warning_screen; mod history_cell; mod log_layer; mod markdown; +mod mouse_capture; mod scroll_event_helper; mod slash_command; mod status_indicator_widget; @@ -152,7 +153,7 @@ fn run_ratatui_app( std::panic::set_hook(Box::new(|info| { tracing::error!("panic: {info}"); })); - let mut terminal = tui::init()?; + let (mut terminal, mut mouse_capture) = tui::init(&config)?; terminal.clear()?; let Cli { prompt, images, .. } = cli; @@ -168,7 +169,7 @@ fn run_ratatui_app( }); } - let app_result = app.run(&mut terminal); + let app_result = app.run(&mut terminal, &mut mouse_capture); restore(); app_result diff --git a/codex-rs/tui/src/mouse_capture.rs b/codex-rs/tui/src/mouse_capture.rs new file mode 100644 index 00000000..cff1296f --- /dev/null +++ b/codex-rs/tui/src/mouse_capture.rs @@ -0,0 +1,69 @@ +use crossterm::event::DisableMouseCapture; +use crossterm::event::EnableMouseCapture; +use ratatui::crossterm::execute; +use std::io::Result; +use std::io::stdout; + +pub(crate) struct MouseCapture { + mouse_capture_is_active: bool, +} + +impl MouseCapture { + pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result { + if mouse_capture_is_active { + enable_capture()?; + } + + Ok(Self { + mouse_capture_is_active, + }) + } +} + +impl MouseCapture { + /// Idempotent method to set the mouse capture state. + pub fn set_active(&mut self, is_active: bool) -> Result<()> { + match (self.mouse_capture_is_active, is_active) { + (true, true) => {} + (false, false) => {} + (true, false) => { + disable_capture()?; + self.mouse_capture_is_active = false; + } + (false, true) => { + enable_capture()?; + self.mouse_capture_is_active = true; + } + } + Ok(()) + } + + pub(crate) fn toggle(&mut self) -> Result<()> { + self.set_active(!self.mouse_capture_is_active) + } + + pub(crate) fn disable(&mut self) -> Result<()> { + if self.mouse_capture_is_active { + disable_capture()?; + self.mouse_capture_is_active = false; + } + Ok(()) + } +} + +impl Drop for MouseCapture { + fn drop(&mut self) { + if self.disable().is_err() { + // The user is likely shutting down, so ignore any errors so the + // shutdown process can complete. + } + } +} + +fn enable_capture() -> Result<()> { + execute!(stdout(), EnableMouseCapture) +} + +fn disable_capture() -> Result<()> { + execute!(stdout(), DisableMouseCapture) +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index c56f2d94..cd6da4da 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -13,6 +13,7 @@ use strum_macros::IntoStaticStr; #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { Clear, + ToggleMouseMode, Quit, } @@ -21,6 +22,9 @@ impl SlashCommand { pub fn description(self) -> &'static str { match self { SlashCommand::Clear => "Clear the chat history.", + SlashCommand::ToggleMouseMode => { + "Toggle mouse mode (enable for scrolling, disable for text selection)" + } SlashCommand::Quit => "Exit the application.", } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 6bbb7e25..99ff0343 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -1,11 +1,11 @@ +use std::io::Result; use std::io::Stdout; use std::io::stdout; -use std::io::{self}; +use codex_core::config::Config; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; -use crossterm::event::EnableMouseCapture; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; @@ -14,17 +14,21 @@ use ratatui::crossterm::terminal::LeaveAlternateScreen; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; +use crate::mouse_capture::MouseCapture; + /// A type alias for the terminal type used in this application pub type Tui = Terminal>; /// Initialize the terminal -pub fn init() -> io::Result { +pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> { execute!(stdout(), EnterAlternateScreen)?; - execute!(stdout(), EnableMouseCapture)?; execute!(stdout(), EnableBracketedPaste)?; + let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?; + enable_raw_mode()?; set_panic_hook(); - Terminal::new(CrosstermBackend::new(stdout())) + let tui = Terminal::new(CrosstermBackend::new(stdout()))?; + Ok((tui, mouse_capture)) } fn set_panic_hook() { @@ -36,8 +40,13 @@ fn set_panic_hook() { } /// Restore the terminal to its original state -pub fn restore() -> io::Result<()> { - execute!(stdout(), DisableMouseCapture)?; +pub fn restore() -> Result<()> { + // We are shutting down, and we cannot reference the `MouseCapture`, so we + // categorically disable mouse capture just to be safe. + if execute!(stdout(), DisableMouseCapture).is_err() { + // It is possible that `DisableMouseCapture` is written more than once + // on shutdown, so ignore the error in this case. + } execute!(stdout(), DisableBracketedPaste)?; execute!(stdout(), LeaveAlternateScreen)?; disable_raw_mode()?;