feat: make it possible to toggle mouse mode in the Rust TUI (#971)
I did a bit of research to understand why I could not use my mouse to drag to select text to copy to the clipboard in iTerm. Apparently https://github.com/openai/codex/pull/641 to enable mousewheel scrolling broke this functionality. It seems that, unless we put in a bit of effort, we can have drag-to-select or scrolling, but not both. Though if you know the trick to hold down `Option` will dragging with the mouse in iTerm, you can probably get by with this. (I did not know about this option prior to researching this issue.) Nevertheless, users may still prefer to disable mouse capture altogether, so this PR introduces: * the ability to set `tui.disable_mouse_capture = true` in `config.toml` to disable mouse capture * a new command, `/toggle-mouse-mode` to toggle mouse capture
This commit is contained in:
1
.codespellignore
Normal file
1
.codespellignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
iTerm
|
||||||
2
.github/workflows/codespell.yml
vendored
2
.github/workflows/codespell.yml
vendored
@@ -23,3 +23,5 @@ jobs:
|
|||||||
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
|
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
|
||||||
- name: Codespell
|
- name: Codespell
|
||||||
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2
|
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2
|
||||||
|
with:
|
||||||
|
ignore_words_file: .codespellignore
|
||||||
|
|||||||
@@ -329,3 +329,21 @@ Currently, `"vscode"` is the default, though Codex does not verify VS Code is in
|
|||||||
### project_doc_max_bytes
|
### 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.
|
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`
|
||||||
|
```
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ pub struct Config {
|
|||||||
/// Optional URI-based file opener. If set, citations to files in the model
|
/// Optional URI-based file opener. If set, citations to files in the model
|
||||||
/// output will be hyperlinked using the specified URI scheme.
|
/// output will be hyperlinked using the specified URI scheme.
|
||||||
pub file_opener: UriBasedFileOpener,
|
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`.
|
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||||
@@ -111,6 +114,23 @@ pub enum HistoryPersistence {
|
|||||||
None,
|
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)]
|
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
|
||||||
pub enum UriBasedFileOpener {
|
pub enum UriBasedFileOpener {
|
||||||
#[serde(rename = "vscode")]
|
#[serde(rename = "vscode")]
|
||||||
@@ -197,6 +217,9 @@ pub struct ConfigToml {
|
|||||||
/// Optional URI-based file opener. If set, citations to files in the model
|
/// Optional URI-based file opener. If set, citations to files in the model
|
||||||
/// output will be hyperlinked using the specified URI scheme.
|
/// output will be hyperlinked using the specified URI scheme.
|
||||||
pub file_opener: Option<UriBasedFileOpener>,
|
pub file_opener: Option<UriBasedFileOpener>,
|
||||||
|
|
||||||
|
/// Collection of settings that are specific to the TUI.
|
||||||
|
pub tui: Option<Tui>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigToml {
|
impl ConfigToml {
|
||||||
@@ -391,6 +414,7 @@ impl Config {
|
|||||||
codex_home,
|
codex_home,
|
||||||
history,
|
history,
|
||||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||||
|
tui: cfg.tui.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -727,6 +751,7 @@ disable_response_storage = true
|
|||||||
codex_home: fixture.codex_home(),
|
codex_home: fixture.codex_home(),
|
||||||
history: History::default(),
|
history: History::default(),
|
||||||
file_opener: UriBasedFileOpener::VsCode,
|
file_opener: UriBasedFileOpener::VsCode,
|
||||||
|
tui: Tui::default(),
|
||||||
},
|
},
|
||||||
o3_profile_config
|
o3_profile_config
|
||||||
);
|
);
|
||||||
@@ -763,6 +788,7 @@ disable_response_storage = true
|
|||||||
codex_home: fixture.codex_home(),
|
codex_home: fixture.codex_home(),
|
||||||
history: History::default(),
|
history: History::default(),
|
||||||
file_opener: UriBasedFileOpener::VsCode,
|
file_opener: UriBasedFileOpener::VsCode,
|
||||||
|
tui: Tui::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||||
@@ -814,6 +840,7 @@ disable_response_storage = true
|
|||||||
codex_home: fixture.codex_home(),
|
codex_home: fixture.codex_home(),
|
||||||
history: History::default(),
|
history: History::default(),
|
||||||
file_opener: UriBasedFileOpener::VsCode,
|
file_opener: UriBasedFileOpener::VsCode,
|
||||||
|
tui: Tui::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::app_event_sender::AppEventSender;
|
|||||||
use crate::chatwidget::ChatWidget;
|
use crate::chatwidget::ChatWidget;
|
||||||
use crate::git_warning_screen::GitWarningOutcome;
|
use crate::git_warning_screen::GitWarningOutcome;
|
||||||
use crate::git_warning_screen::GitWarningScreen;
|
use crate::git_warning_screen::GitWarningScreen;
|
||||||
|
use crate::mouse_capture::MouseCapture;
|
||||||
use crate::scroll_event_helper::ScrollEventHelper;
|
use crate::scroll_event_helper::ScrollEventHelper;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
@@ -122,7 +123,11 @@ impl App<'_> {
|
|||||||
self.app_event_tx.clone()
|
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.
|
// Insert an event to trigger the first render.
|
||||||
let app_event_tx = self.app_event_tx.clone();
|
let app_event_tx = self.app_event_tx.clone();
|
||||||
app_event_tx.send(AppEvent::Redraw);
|
app_event_tx.send(AppEvent::Redraw);
|
||||||
@@ -176,6 +181,11 @@ impl App<'_> {
|
|||||||
SlashCommand::Clear => {
|
SlashCommand::Clear => {
|
||||||
self.chat_widget.clear_conversation_history();
|
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 => {
|
SlashCommand::Quit => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use crate::slash_command::SlashCommand;
|
|||||||
use crate::slash_command::built_in_slash_commands;
|
use crate::slash_command::built_in_slash_commands;
|
||||||
|
|
||||||
const MAX_POPUP_ROWS: usize = 5;
|
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;
|
use ratatui::style::Modifier;
|
||||||
|
|
||||||
@@ -176,15 +178,18 @@ impl WidgetRef for CommandPopup {
|
|||||||
|
|
||||||
use ratatui::layout::Constraint;
|
use ratatui::layout::Constraint;
|
||||||
|
|
||||||
let table = Table::new(rows, [Constraint::Length(15), Constraint::Min(10)])
|
let table = Table::new(
|
||||||
.style(style)
|
rows,
|
||||||
.column_spacing(1)
|
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
|
||||||
.block(
|
)
|
||||||
Block::default()
|
.style(style)
|
||||||
.borders(Borders::ALL)
|
.column_spacing(1)
|
||||||
.border_type(BorderType::Rounded)
|
.block(
|
||||||
.style(style),
|
Block::default()
|
||||||
);
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.style(style),
|
||||||
|
);
|
||||||
|
|
||||||
table.render(area, buf);
|
table.render(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod git_warning_screen;
|
|||||||
mod history_cell;
|
mod history_cell;
|
||||||
mod log_layer;
|
mod log_layer;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
|
mod mouse_capture;
|
||||||
mod scroll_event_helper;
|
mod scroll_event_helper;
|
||||||
mod slash_command;
|
mod slash_command;
|
||||||
mod status_indicator_widget;
|
mod status_indicator_widget;
|
||||||
@@ -152,7 +153,7 @@ fn run_ratatui_app(
|
|||||||
std::panic::set_hook(Box::new(|info| {
|
std::panic::set_hook(Box::new(|info| {
|
||||||
tracing::error!("panic: {info}");
|
tracing::error!("panic: {info}");
|
||||||
}));
|
}));
|
||||||
let mut terminal = tui::init()?;
|
let (mut terminal, mut mouse_capture) = tui::init(&config)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
|
|
||||||
let Cli { prompt, images, .. } = cli;
|
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();
|
restore();
|
||||||
app_result
|
app_result
|
||||||
|
|||||||
69
codex-rs/tui/src/mouse_capture.rs
Normal file
69
codex-rs/tui/src/mouse_capture.rs
Normal file
@@ -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<Self> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use strum_macros::IntoStaticStr;
|
|||||||
#[strum(serialize_all = "kebab-case")]
|
#[strum(serialize_all = "kebab-case")]
|
||||||
pub enum SlashCommand {
|
pub enum SlashCommand {
|
||||||
Clear,
|
Clear,
|
||||||
|
ToggleMouseMode,
|
||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ impl SlashCommand {
|
|||||||
pub fn description(self) -> &'static str {
|
pub fn description(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
SlashCommand::Clear => "Clear the chat history.",
|
SlashCommand::Clear => "Clear the chat history.",
|
||||||
|
SlashCommand::ToggleMouseMode => {
|
||||||
|
"Toggle mouse mode (enable for scrolling, disable for text selection)"
|
||||||
|
}
|
||||||
SlashCommand::Quit => "Exit the application.",
|
SlashCommand::Quit => "Exit the application.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
use std::io::Result;
|
||||||
use std::io::Stdout;
|
use std::io::Stdout;
|
||||||
use std::io::stdout;
|
use std::io::stdout;
|
||||||
use std::io::{self};
|
|
||||||
|
|
||||||
|
use codex_core::config::Config;
|
||||||
use crossterm::event::DisableBracketedPaste;
|
use crossterm::event::DisableBracketedPaste;
|
||||||
use crossterm::event::DisableMouseCapture;
|
use crossterm::event::DisableMouseCapture;
|
||||||
use crossterm::event::EnableBracketedPaste;
|
use crossterm::event::EnableBracketedPaste;
|
||||||
use crossterm::event::EnableMouseCapture;
|
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
@@ -14,17 +14,21 @@ use ratatui::crossterm::terminal::LeaveAlternateScreen;
|
|||||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||||
use ratatui::crossterm::terminal::enable_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
|
/// A type alias for the terminal type used in this application
|
||||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
/// Initialize the terminal
|
/// Initialize the terminal
|
||||||
pub fn init() -> io::Result<Tui> {
|
pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {
|
||||||
execute!(stdout(), EnterAlternateScreen)?;
|
execute!(stdout(), EnterAlternateScreen)?;
|
||||||
execute!(stdout(), EnableMouseCapture)?;
|
|
||||||
execute!(stdout(), EnableBracketedPaste)?;
|
execute!(stdout(), EnableBracketedPaste)?;
|
||||||
|
let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
set_panic_hook();
|
set_panic_hook();
|
||||||
Terminal::new(CrosstermBackend::new(stdout()))
|
let tui = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||||
|
Ok((tui, mouse_capture))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_panic_hook() {
|
fn set_panic_hook() {
|
||||||
@@ -36,8 +40,13 @@ fn set_panic_hook() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the terminal to its original state
|
/// Restore the terminal to its original state
|
||||||
pub fn restore() -> io::Result<()> {
|
pub fn restore() -> Result<()> {
|
||||||
execute!(stdout(), DisableMouseCapture)?;
|
// 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(), DisableBracketedPaste)?;
|
||||||
execute!(stdout(), LeaveAlternateScreen)?;
|
execute!(stdout(), LeaveAlternateScreen)?;
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user