From 575590e4c2e432e8cf5392a5214da577621d4c8d Mon Sep 17 00:00:00 2001 From: easong-openai Date: Thu, 31 Jul 2025 17:30:44 -0700 Subject: [PATCH] Detect kitty terminals (#1748) We want to detect kitty terminals so we can preferentially upgrade their UX without degrading older terminals. --- codex-rs/tui/Cargo.toml | 2 ++ codex-rs/tui/src/app.rs | 12 +++++++ .../src/bottom_pane/approval_modal_view.rs | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 31 +++++++++++++------ codex-rs/tui/src/bottom_pane/mod.rs | 9 +++++- ...mposer__tests__backspace_after_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...tom_pane__chat_composer__tests__large.snap | 2 +- ...chat_composer__tests__multiple_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__small.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 2 ++ 11 files changed, 52 insertions(+), 15 deletions(-) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 63d287ca..468f2f3b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -62,6 +62,8 @@ unicode-segmentation = "1.12.0" unicode-width = "0.1" uuid = "1" + + [dev-dependencies] insta = "1.43.1" pretty_assertions = "1" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a03cc899..cc3118e8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -15,6 +15,7 @@ use crossterm::SynchronizedUpdate; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::terminal::supports_keyboard_enhancement; use ratatui::layout::Offset; use ratatui::prelude::Backend; use ratatui::text::Line; @@ -61,6 +62,8 @@ pub(crate) struct App<'a> { /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, + + enhanced_keys_supported: bool, } /// Aggregate parameters needed to create a `ChatWidget`, as creation may be @@ -70,6 +73,7 @@ struct ChatWidgetArgs { config: Config, initial_prompt: Option, initial_images: Vec, + enhanced_keys_supported: bool, } impl App<'_> { @@ -83,6 +87,8 @@ impl App<'_> { let app_event_tx = AppEventSender::new(app_event_tx); let pending_redraw = Arc::new(AtomicBool::new(false)); + let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); + // Spawn a dedicated thread for reading the crossterm event loop and // re-publishing the events as AppEvents, as appropriate. { @@ -135,6 +141,7 @@ impl App<'_> { config: config.clone(), initial_prompt, initial_images, + enhanced_keys_supported, }), ) } else { @@ -143,6 +150,7 @@ impl App<'_> { app_event_tx.clone(), initial_prompt, initial_images, + enhanced_keys_supported, ); ( AppState::Chat { @@ -162,6 +170,7 @@ impl App<'_> { file_search, pending_redraw, chat_args, + enhanced_keys_supported, } } @@ -284,6 +293,7 @@ impl App<'_> { self.app_event_tx.clone(), None, Vec::new(), + self.enhanced_keys_supported, )); self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); @@ -400,6 +410,7 @@ impl App<'_> { AppState::Chat { widget } => widget.desired_height(size.width), AppState::GitWarning { .. } => 10, }; + let mut area = terminal.viewport_area; area.height = desired_height.min(size.height); area.width = size.width; @@ -451,6 +462,7 @@ impl App<'_> { self.app_event_tx.clone(), args.initial_prompt, args.initial_images, + args.enhanced_keys_supported, )); self.app_state = AppState::Chat { widget }; self.app_event_tx.send(AppEvent::RequestRedraw); diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index 4cd952f9..8e9ff6d9 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -99,6 +99,7 @@ mod tests { let mut pane = BottomPane::new(super::super::BottomPaneParams { app_event_tx: AppEventSender::new(tx_raw2), has_input_focus: true, + enhanced_keys_supported: false, }); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); assert!(view.queue.is_empty()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3bc573a0..7e187bec 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -41,6 +41,7 @@ pub(crate) struct ChatComposer<'a> { app_event_tx: AppEventSender, history: ChatComposerHistory, ctrl_c_quit_hint: bool, + use_shift_enter_hint: bool, dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, @@ -54,17 +55,24 @@ enum ActivePopup { } impl ChatComposer<'_> { - pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self { + pub fn new( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + ) -> Self { let mut textarea = TextArea::default(); textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT); textarea.set_cursor_line_style(ratatui::style::Style::default()); + let use_shift_enter_hint = enhanced_keys_supported; + let mut this = Self { textarea, active_popup: ActivePopup::None, app_event_tx, history: ChatComposerHistory::new(), ctrl_c_quit_hint: false, + use_shift_enter_hint, dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), @@ -712,11 +720,16 @@ impl WidgetRef for &ChatComposer<'_> { Span::from(" to quit"), ] } else { + let newline_hint_key = if self.use_shift_enter_hint { + "Shift+⏎" + } else { + "Ctrl+J" + }; vec![ Span::from(" "), "⏎".set_style(key_hint_style), Span::from(" send "), - "Shift+⏎".set_style(key_hint_style), + newline_hint_key.set_style(key_hint_style), Span::from(" newline "), "Ctrl+C".set_style(key_hint_style), Span::from(" quit"), @@ -890,7 +903,7 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new(true, sender); + let mut composer = ChatComposer::new(true, sender, false); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -913,7 +926,7 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new(true, sender); + let mut composer = ChatComposer::new(true, sender, false); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -942,7 +955,7 @@ mod tests { let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new(true, sender); + let mut composer = ChatComposer::new(true, sender, false); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -978,7 +991,7 @@ mod tests { for (name, input) in test_cases { // Create a fresh composer for each test case - let mut composer = ChatComposer::new(true, sender.clone()); + let mut composer = ChatComposer::new(true, sender.clone(), false); if let Some(text) = input { composer.handle_paste(text); @@ -1015,7 +1028,7 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new(true, sender); + let mut composer = ChatComposer::new(true, sender, false); // Define test cases: (paste content, is_large) let test_cases = [ @@ -1088,7 +1101,7 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new(true, sender); + let mut composer = ChatComposer::new(true, sender, false); // Define test cases: (content, is_large) let test_cases = [ @@ -1161,7 +1174,7 @@ mod tests { let (tx, _rx) = std::sync::mpsc::channel(); let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new(true, sender); + let mut composer = ChatComposer::new(true, sender, false); // Define test cases: (cursor_position_from_end, expected_pending_count) let test_cases = [ diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2710a3e9..281f0859 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -50,12 +50,18 @@ pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, pub(crate) has_input_focus: bool, + pub(crate) enhanced_keys_supported: bool, } impl BottomPane<'_> { pub fn new(params: BottomPaneParams) -> Self { + let enhanced_keys_supported = params.enhanced_keys_supported; Self { - composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()), + composer: ChatComposer::new( + params.has_input_focus, + params.app_event_tx.clone(), + enhanced_keys_supported, + ), active_view: None, app_event_tx: params.app_event_tx, has_input_focus: params.has_input_focus, @@ -298,6 +304,7 @@ mod tests { let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, has_input_focus: true, + enhanced_keys_supported: false, }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index 4f155dab..178e2cc4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " "▌ " -" ⏎ send Shift+⏎ newline Ctrl+C quit " +" ⏎ send Ctrl+J newline Ctrl+C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index 4e8371f1..7a1a7f4e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " "▌ " -" ⏎ send Shift+⏎ newline Ctrl+C quit " +" ⏎ send Ctrl+J newline Ctrl+C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 80fea40d..d3f67c92 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " "▌ " -" ⏎ send Shift+⏎ newline Ctrl+C quit " +" ⏎ send Ctrl+J newline Ctrl+C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 26e8d267..999e7b2e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " "▌ " -" ⏎ send Shift+⏎ newline Ctrl+C quit " +" ⏎ send Ctrl+J newline Ctrl+C quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 0f1b9e64..68d7711a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() "▌ " "▌ " "▌ " -" ⏎ send Shift+⏎ newline Ctrl+C quit " +" ⏎ send Ctrl+J newline Ctrl+C quit " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0a6fb44..4c415777 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -95,6 +95,7 @@ impl ChatWidget<'_> { app_event_tx: AppEventSender, initial_prompt: Option, initial_images: Vec, + enhanced_keys_supported: bool, ) -> Self { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); @@ -140,6 +141,7 @@ impl ChatWidget<'_> { bottom_pane: BottomPane::new(BottomPaneParams { app_event_tx, has_input_focus: true, + enhanced_keys_supported, }), config, initial_user_message: create_initial_user_message(