mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
feat: make it possible to toggle mouse mode in the Rust TUI
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
|
||||
- name: Codespell
|
||||
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
|
||||
|
||||
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
|
||||
/// 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<UriBasedFileOpener>,
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
pub tui: Option<Tui>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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")]
|
||||
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.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal
|
||||
pub fn init() -> io::Result<Tui> {
|
||||
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()?;
|
||||
|
||||
Reference in New Issue
Block a user