mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5e0a4adbe5
## Why Granular copy is particularly difficult with the current output. Part of it was solved with the introduction of the `/copy` command but when you only need to copy parts of a response, you still encounter some issues: - When you copy a paragraph, the result is a sequence of separate lines instead of one correctly joined paragraph. - When a word wraps, part of it stays on the original line and the rest appears at the start of the next line. - When you copy a long command, extra line breaks are often inserted, and command arguments can be split across multiple lines. https://github.com/user-attachments/assets/0ef85c84-9363-4aad-b43a-15fce062a443 ## Solution Now that we own the scrollback and we re-create it when we resize, we have the opportunity of toggling between the raw text and the rich text we see today. - Add TUI raw scrollback mode with `tui.raw_output_mode`, `/raw [on|off]`, and the configurable `tui.keymap.global.toggle_raw_output` action. - Render transcript cells through rich/raw-aware paths so raw mode preserves source text and lets the terminal soft-wrap selection-friendly output. - Bind raw-mode toggle to `alt-r` by default, with the keybinding path toggling silently while `/raw` continues to emit confirmation messages. ## Related Issues Likely addressed by raw mode: - #12200: clean copy for multiline and soft-wrapped output. Raw mode removes Codex-inserted wrapping/indentation and lets the terminal soft-wrap logical lines. - #9252: command suggestions gain unwanted leading spaces when copied. Raw mode renders transcript text without the rich-mode left padding/gutter. - #8258: prompt output is hard to copy because of leading indentation. Raw mode renders user/source-backed transcript text without that decorative indentation. Partially or conditionally addressed: - #2880: copy/export message as Markdown. Raw mode exposes raw Markdown for terminal selection, but this PR does not add a dedicated export/copy-message command. - #19820: mouse drag selection + copy in the TUI. Raw mode improves terminal-native selection of output/history text, but this PR does not implement in-TUI mouse selection, highlighting, auto-copy, or composer selection. - #18979: copied content is divided into two parts. This should improve cases caused by Codex-inserted wraps/padding in rendered output; if the report is about pasting into the composer/input path, that remains outside this PR. ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-config` - `cargo test -p codex-tui` - `just fix -p codex-tui` - `just argument-comment-lint` - `cargo test -p codex-tui raw_output_mode_can_change_without_inserting_notice -- --nocapture` - `cargo test -p codex-tui raw_slash_command_toggles_and_accepts_on_off_args -- --nocapture` - `cargo test -p codex-tui raw_output_toggle -- --nocapture` - `git diff --check` - `cargo insta pending-snapshots`
284 lines
11 KiB
Rust
284 lines
11 KiB
Rust
//! Keyboard input, external editor, and status-line dispatch for the TUI app.
|
|
//!
|
|
//! This module owns global key bindings that sit above ChatWidget, including transcript overlay
|
|
//! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts.
|
|
|
|
use super::*;
|
|
|
|
impl App {
|
|
pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
|
|
let editor_cmd = match external_editor::resolve_editor_command() {
|
|
Ok(cmd) => cmd,
|
|
Err(external_editor::EditorError::MissingEditor) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(
|
|
"Cannot open external editor: set $VISUAL or $EDITOR before starting Codex."
|
|
.to_string(),
|
|
));
|
|
self.reset_external_editor_state(tui);
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!(
|
|
"Failed to open editor: {err}",
|
|
)));
|
|
self.reset_external_editor_state(tui);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let seed = self.chat_widget.composer_text_with_pending();
|
|
let editor_result = tui
|
|
.with_restored(tui::RestoreMode::KeepRaw, || async {
|
|
external_editor::run_editor(&seed, &editor_cmd).await
|
|
})
|
|
.await;
|
|
self.reset_external_editor_state(tui);
|
|
|
|
match editor_result {
|
|
Ok(new_text) => {
|
|
// Trim trailing whitespace
|
|
let cleaned = new_text.trim_end().to_string();
|
|
self.chat_widget.apply_external_edit(cleaned);
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!(
|
|
"Failed to open editor: {err}",
|
|
)));
|
|
}
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
pub(super) fn request_external_editor_launch(&mut self, tui: &mut tui::Tui) {
|
|
self.chat_widget
|
|
.set_external_editor_state(ExternalEditorState::Requested);
|
|
self.chat_widget.set_footer_hint_override(Some(vec![(
|
|
EXTERNAL_EDITOR_HINT.to_string(),
|
|
String::new(),
|
|
)]));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
pub(super) fn reset_external_editor_state(&mut self, tui: &mut tui::Tui) {
|
|
self.chat_widget
|
|
.set_external_editor_state(ExternalEditorState::Closed);
|
|
self.chat_widget.set_footer_hint_override(/*items*/ None);
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
pub(super) fn apply_raw_output_mode(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
enabled: bool,
|
|
notify: bool,
|
|
) {
|
|
if notify {
|
|
self.chat_widget.set_raw_output_mode_and_notify(enabled);
|
|
} else {
|
|
self.chat_widget.set_raw_output_mode(enabled);
|
|
}
|
|
if let Err(err) = self.reflow_transcript_now(tui) {
|
|
tracing::warn!(error = %err, "failed to reflow transcript after raw output mode toggle");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to redraw transcript: {err}"));
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
pub(super) async fn handle_key_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
app_server: &mut AppServerSession,
|
|
key_event: KeyEvent,
|
|
) {
|
|
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
|
|
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
|
|
// agent-switch shortcuts when the composer is empty so we never steal the expected
|
|
// editing behavior for moving across words inside a draft.
|
|
let allow_agent_word_motion_fallback = !self.enhanced_keys_supported
|
|
&& self.chat_widget.composer_text_with_pending().is_empty();
|
|
if self.overlay.is_none()
|
|
&& self.chat_widget.no_modal_or_popup_active()
|
|
// Alt+Left/Right are also natural word-motion keys in the composer. Keep agent
|
|
// fast-switch available only once the draft is empty so editing behavior wins whenever
|
|
// there is text on screen.
|
|
&& self.chat_widget.composer_text_with_pending().is_empty()
|
|
&& previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback)
|
|
{
|
|
if let Some(thread_id) = self
|
|
.adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous)
|
|
.await
|
|
{
|
|
let _ = self
|
|
.select_agent_thread_and_discard_side(tui, app_server, thread_id)
|
|
.await;
|
|
}
|
|
return;
|
|
}
|
|
if self.overlay.is_none()
|
|
&& self.chat_widget.no_modal_or_popup_active()
|
|
// Mirror the previous-agent rule above: empty drafts may use these keys for thread
|
|
// switching, but non-empty drafts keep them for expected word-wise cursor motion.
|
|
&& self.chat_widget.composer_text_with_pending().is_empty()
|
|
&& next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback)
|
|
{
|
|
if let Some(thread_id) = self
|
|
.adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next)
|
|
.await
|
|
{
|
|
let _ = self
|
|
.select_agent_thread_and_discard_side(tui, app_server, thread_id)
|
|
.await;
|
|
}
|
|
return;
|
|
}
|
|
if side_return_shortcut_matches(key_event)
|
|
&& self.maybe_return_from_side(tui, app_server).await
|
|
{
|
|
return;
|
|
}
|
|
|
|
let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available();
|
|
|
|
if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) {
|
|
self.chat_widget.toggle_vim_mode_and_notify();
|
|
return;
|
|
}
|
|
|
|
if app_keymap_shortcuts_available
|
|
&& self.keymap.app.toggle_fast_mode.is_pressed(key_event)
|
|
&& self.chat_widget.can_toggle_fast_mode_from_keybinding()
|
|
{
|
|
self.chat_widget.toggle_fast_mode_from_ui();
|
|
return;
|
|
}
|
|
|
|
if app_keymap_shortcuts_available && self.keymap.app.toggle_raw_output.is_pressed(key_event)
|
|
{
|
|
let enabled = !self.chat_widget.raw_output_mode();
|
|
self.apply_raw_output_mode(tui, enabled, /*notify*/ false);
|
|
return;
|
|
}
|
|
|
|
if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
|
|
// Enter alternate screen and set viewport to full size.
|
|
let _ = tui.enter_alt_screen();
|
|
self.overlay = Some(Overlay::new_transcript(
|
|
self.transcript_cells.clone(),
|
|
self.keymap.pager.clone(),
|
|
));
|
|
tui.frame_requester().schedule_frame();
|
|
return;
|
|
}
|
|
|
|
if app_keymap_shortcuts_available
|
|
&& self.keymap.app.open_external_editor.is_pressed(key_event)
|
|
{
|
|
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
|
|
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
|
|
if self.overlay.is_none()
|
|
&& self.chat_widget.can_launch_external_editor()
|
|
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
|
|
{
|
|
self.request_external_editor_launch(tui);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if matches!(key_event.code, KeyCode::Esc)
|
|
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
|
{
|
|
// Esc primes/advances backtracking only in normal (not working) mode
|
|
// with the composer focused and empty. In any other state, forward
|
|
// Esc so the active UI (e.g. status indicator, modals, popups)
|
|
// handles it.
|
|
if self.should_handle_backtrack_esc(key_event) {
|
|
self.handle_backtrack_esc_key(tui);
|
|
} else {
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
return;
|
|
}
|
|
|
|
match key_event {
|
|
_ if app_keymap_shortcuts_available
|
|
&& self.keymap.app.clear_terminal.is_pressed(key_event) =>
|
|
{
|
|
if !self.chat_widget.can_run_ctrl_l_clear_now() {
|
|
return;
|
|
}
|
|
if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) {
|
|
tracing::warn!(error = %err, "failed to clear terminal UI");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to clear terminal UI: {err}"));
|
|
} else {
|
|
self.reset_app_ui_state_after_clear();
|
|
self.queue_clear_ui_header(tui);
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
}
|
|
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if self.backtrack.primed
|
|
&& self.backtrack.nth_user_message != usize::MAX
|
|
&& self.chat_widget.composer_is_empty() =>
|
|
{
|
|
if let Some(selection) = self.confirm_backtrack_from_main() {
|
|
self.apply_backtrack_selection(tui, selection);
|
|
}
|
|
}
|
|
KeyEvent {
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
// Any non-Esc key press should cancel a primed backtrack.
|
|
// This avoids stale "Esc-primed" state after the user starts typing
|
|
// (even if they later backspace to empty).
|
|
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
|
self.reset_backtrack_state();
|
|
}
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
_ => {
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
};
|
|
}
|
|
|
|
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
|
|
self.chat_widget.is_normal_backtrack_mode()
|
|
&& self.chat_widget.composer_is_empty()
|
|
&& !self.chat_widget.should_handle_vim_insert_escape(key_event)
|
|
}
|
|
|
|
fn app_keymap_shortcuts_available(&self) -> bool {
|
|
self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active()
|
|
}
|
|
|
|
pub(super) fn refresh_status_line(&mut self) {
|
|
self.chat_widget.refresh_status_line();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::test_support::make_test_app;
|
|
|
|
#[tokio::test]
|
|
async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() {
|
|
let mut app = make_test_app().await;
|
|
assert!(app.app_keymap_shortcuts_available());
|
|
|
|
let keymap = app.keymap.clone();
|
|
app.chat_widget.open_keymap_debug(&keymap);
|
|
|
|
assert!(!app.app_keymap_shortcuts_available());
|
|
}
|
|
}
|