Detect kitty terminals (#1748)

We want to detect kitty terminals so we can preferentially upgrade their UX without degrading older terminals.
This commit is contained in:
easong-openai
2025-07-31 17:30:44 -07:00
committed by GitHub
parent 4aca3e46c8
commit 575590e4c2
11 changed files with 52 additions and 15 deletions

View File

@@ -62,6 +62,8 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1" unicode-width = "0.1"
uuid = "1" uuid = "1"
[dev-dependencies] [dev-dependencies]
insta = "1.43.1" insta = "1.43.1"
pretty_assertions = "1" pretty_assertions = "1"

View File

@@ -15,6 +15,7 @@ use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset; use ratatui::layout::Offset;
use ratatui::prelude::Backend; use ratatui::prelude::Backend;
use ratatui::text::Line; use ratatui::text::Line;
@@ -61,6 +62,8 @@ pub(crate) struct App<'a> {
/// Stored parameters needed to instantiate the ChatWidget later, e.g., /// Stored parameters needed to instantiate the ChatWidget later, e.g.,
/// after dismissing the Git-repo warning. /// after dismissing the Git-repo warning.
chat_args: Option<ChatWidgetArgs>, chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
} }
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be /// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -70,6 +73,7 @@ struct ChatWidgetArgs {
config: Config, config: Config,
initial_prompt: Option<String>, initial_prompt: Option<String>,
initial_images: Vec<PathBuf>, initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
} }
impl App<'_> { impl App<'_> {
@@ -83,6 +87,8 @@ impl App<'_> {
let app_event_tx = AppEventSender::new(app_event_tx); let app_event_tx = AppEventSender::new(app_event_tx);
let pending_redraw = Arc::new(AtomicBool::new(false)); 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 // Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate. // re-publishing the events as AppEvents, as appropriate.
{ {
@@ -135,6 +141,7 @@ impl App<'_> {
config: config.clone(), config: config.clone(),
initial_prompt, initial_prompt,
initial_images, initial_images,
enhanced_keys_supported,
}), }),
) )
} else { } else {
@@ -143,6 +150,7 @@ impl App<'_> {
app_event_tx.clone(), app_event_tx.clone(),
initial_prompt, initial_prompt,
initial_images, initial_images,
enhanced_keys_supported,
); );
( (
AppState::Chat { AppState::Chat {
@@ -162,6 +170,7 @@ impl App<'_> {
file_search, file_search,
pending_redraw, pending_redraw,
chat_args, chat_args,
enhanced_keys_supported,
} }
} }
@@ -284,6 +293,7 @@ impl App<'_> {
self.app_event_tx.clone(), self.app_event_tx.clone(),
None, None,
Vec::new(), Vec::new(),
self.enhanced_keys_supported,
)); ));
self.app_state = AppState::Chat { widget: new_widget }; self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw); self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -400,6 +410,7 @@ impl App<'_> {
AppState::Chat { widget } => widget.desired_height(size.width), AppState::Chat { widget } => widget.desired_height(size.width),
AppState::GitWarning { .. } => 10, AppState::GitWarning { .. } => 10,
}; };
let mut area = terminal.viewport_area; let mut area = terminal.viewport_area;
area.height = desired_height.min(size.height); area.height = desired_height.min(size.height);
area.width = size.width; area.width = size.width;
@@ -451,6 +462,7 @@ impl App<'_> {
self.app_event_tx.clone(), self.app_event_tx.clone(),
args.initial_prompt, args.initial_prompt,
args.initial_images, args.initial_images,
args.enhanced_keys_supported,
)); ));
self.app_state = AppState::Chat { widget }; self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw); self.app_event_tx.send(AppEvent::RequestRedraw);

View File

@@ -99,6 +99,7 @@ mod tests {
let mut pane = BottomPane::new(super::super::BottomPaneParams { let mut pane = BottomPane::new(super::super::BottomPaneParams {
app_event_tx: AppEventSender::new(tx_raw2), app_event_tx: AppEventSender::new(tx_raw2),
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false,
}); });
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
assert!(view.queue.is_empty()); assert!(view.queue.is_empty());

View File

@@ -41,6 +41,7 @@ pub(crate) struct ChatComposer<'a> {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
history: ChatComposerHistory, history: ChatComposerHistory,
ctrl_c_quit_hint: bool, ctrl_c_quit_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>, dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>, current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>, pending_pastes: Vec<(String, String)>,
@@ -54,17 +55,24 @@ enum ActivePopup {
} }
impl ChatComposer<'_> { 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(); let mut textarea = TextArea::default();
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT); textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
textarea.set_cursor_line_style(ratatui::style::Style::default()); textarea.set_cursor_line_style(ratatui::style::Style::default());
let use_shift_enter_hint = enhanced_keys_supported;
let mut this = Self { let mut this = Self {
textarea, textarea,
active_popup: ActivePopup::None, active_popup: ActivePopup::None,
app_event_tx, app_event_tx,
history: ChatComposerHistory::new(), history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false, ctrl_c_quit_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None, dismissed_file_popup_token: None,
current_file_query: None, current_file_query: None,
pending_pastes: Vec::new(), pending_pastes: Vec::new(),
@@ -712,11 +720,16 @@ impl WidgetRef for &ChatComposer<'_> {
Span::from(" to quit"), Span::from(" to quit"),
] ]
} else { } else {
let newline_hint_key = if self.use_shift_enter_hint {
"Shift+⏎"
} else {
"Ctrl+J"
};
vec![ vec![
Span::from(" "), Span::from(" "),
"".set_style(key_hint_style), "".set_style(key_hint_style),
Span::from(" send "), Span::from(" send "),
"Shift+⏎".set_style(key_hint_style), newline_hint_key.set_style(key_hint_style),
Span::from(" newline "), Span::from(" newline "),
"Ctrl+C".set_style(key_hint_style), "Ctrl+C".set_style(key_hint_style),
Span::from(" quit"), Span::from(" quit"),
@@ -890,7 +903,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx); 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()); let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw); assert!(needs_redraw);
@@ -913,7 +926,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx); 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 large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_paste(large.clone()); let needs_redraw = composer.handle_paste(large.clone());
@@ -942,7 +955,7 @@ mod tests {
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx); let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender); let mut composer = ChatComposer::new(true, sender, false);
composer.handle_paste(large); composer.handle_paste(large);
assert_eq!(composer.pending_pastes.len(), 1); assert_eq!(composer.pending_pastes.len(), 1);
@@ -978,7 +991,7 @@ mod tests {
for (name, input) in test_cases { for (name, input) in test_cases {
// Create a fresh composer for each test case // 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 { if let Some(text) = input {
composer.handle_paste(text); composer.handle_paste(text);
@@ -1015,7 +1028,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx); 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) // Define test cases: (paste content, is_large)
let test_cases = [ let test_cases = [
@@ -1088,7 +1101,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx); 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) // Define test cases: (content, is_large)
let test_cases = [ let test_cases = [
@@ -1161,7 +1174,7 @@ mod tests {
let (tx, _rx) = std::sync::mpsc::channel(); let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx); 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) // Define test cases: (cursor_position_from_end, expected_pending_count)
let test_cases = [ let test_cases = [

View File

@@ -50,12 +50,18 @@ pub(crate) struct BottomPane<'a> {
pub(crate) struct BottomPaneParams { pub(crate) struct BottomPaneParams {
pub(crate) app_event_tx: AppEventSender, pub(crate) app_event_tx: AppEventSender,
pub(crate) has_input_focus: bool, pub(crate) has_input_focus: bool,
pub(crate) enhanced_keys_supported: bool,
} }
impl BottomPane<'_> { impl BottomPane<'_> {
pub fn new(params: BottomPaneParams) -> Self { pub fn new(params: BottomPaneParams) -> Self {
let enhanced_keys_supported = params.enhanced_keys_supported;
Self { 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, active_view: None,
app_event_tx: params.app_event_tx, app_event_tx: params.app_event_tx,
has_input_focus: params.has_input_focus, has_input_focus: params.has_input_focus,
@@ -298,6 +304,7 @@ mod tests {
let mut pane = BottomPane::new(BottomPaneParams { let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx, app_event_tx: tx,
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported: false,
}); });
pane.push_approval_request(exec_request()); pane.push_approval_request(exec_request());
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ " "▌ "
"▌ " "▌ "
"▌ " "▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit " " ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ " "▌ "
"▌ " "▌ "
"▌ " "▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit " " ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ " "▌ "
"▌ " "▌ "
"▌ " "▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit " " ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ " "▌ "
"▌ " "▌ "
"▌ " "▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit " " ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ " "▌ "
"▌ " "▌ "
"▌ " "▌ "
" ⏎ send Shift+⏎ newline Ctrl+C quit " " ⏎ send Ctrl+J newline Ctrl+C quit "

View File

@@ -95,6 +95,7 @@ impl ChatWidget<'_> {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
initial_prompt: Option<String>, initial_prompt: Option<String>,
initial_images: Vec<PathBuf>, initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
) -> Self { ) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>(); let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -140,6 +141,7 @@ impl ChatWidget<'_> {
bottom_pane: BottomPane::new(BottomPaneParams { bottom_pane: BottomPane::new(BottomPaneParams {
app_event_tx, app_event_tx,
has_input_focus: true, has_input_focus: true,
enhanced_keys_supported,
}), }),
config, config,
initial_user_message: create_initial_user_message( initial_user_message: create_initial_user_message(