返回 CodeWhale
composer_ui.rs
根目录 / crates / tui / src / tui / composer_ui.rs
1 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3 use crate::tui::app::{App, ComposerSubmitChord};
4
5 const COMPOSER_ARROW_SCROLL_LINES: usize = 3;
6
7 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
8 pub(crate) enum EscapeAction {
9 CloseSlashMenu,
10 CancelRequest,
11 PauseCommand,
12 DiscardQueuedDraft,
13 ClearInput,
14 Noop,
15 }
16
17 pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction {
18 if slash_menu_open {
19 EscapeAction::CloseSlashMenu
20 } else if app.queued_draft.is_some() {
21 EscapeAction::DiscardQueuedDraft
22 } else if app.paused || app.paused_quarry.is_some() {
23 EscapeAction::CancelRequest
24 } else if app.pausable
25 && !app.paused
26 && (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")))
27 {
28 EscapeAction::PauseCommand
29 } else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
30 EscapeAction::CancelRequest
31 } else if !app.input.is_empty() {
32 EscapeAction::ClearInput
33 } else {
34 EscapeAction::Noop
35 }
36 }
37
38 pub(crate) fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) {
39 if entry_count == 0 {
40 return;
41 }
42 let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1));
43 app.slash_menu_selected = (selected + entry_count - 1) % entry_count;
44 }
45
46 pub(crate) fn select_next_slash_menu_entry(app: &mut App, entry_count: usize) {
47 if entry_count == 0 {
48 return;
49 }
50 let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1));
51 app.slash_menu_selected = (selected + 1) % entry_count;
52 }
53
54 pub(crate) fn handle_composer_history_arrow(
55 app: &mut App,
56 key: KeyEvent,
57 slash_menu_open: bool,
58 mention_menu_open: bool,
59 ) -> bool {
60 if slash_menu_open || mention_menu_open {
61 return false;
62 }
63 if key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER) {
64 return false;
65 }
66
67 // When `composer_arrows_scroll` is enabled, plain Up/Down scroll the
68 // transcript for single-line drafts. Multiline drafts keep editor-like
69 // line navigation. If the user holds Up/Down at the first/last line, do
70 // not replace their current draft with prompt history unless they are
71 // already navigating history — scroll the transcript instead. Terminals
72 // that convert the wheel into arrow keys (iTerm2's alternate-screen
73 // setting) reach the composer through this path, so a draft boundary that
74 // merely redraws would strand the user with no way to scroll back (#5223).
75 let scroll_transcript = app.composer_arrows_scroll && !app.input.contains('\n');
76 let protect_multiline_draft = app.input.contains('\n') && app.history_index.is_none();
77
78 match key.code {
79 KeyCode::Up => {
80 if scroll_transcript
81 || (protect_multiline_draft && !cursor_has_previous_logical_line(app))
82 {
83 app.scroll_up(COMPOSER_ARROW_SCROLL_LINES);
84 } else {
85 app.vim_move_up();
86 }
87 true
88 }
89 KeyCode::Down => {
90 if scroll_transcript || (protect_multiline_draft && !cursor_has_next_logical_line(app))
91 {
92 app.scroll_down(COMPOSER_ARROW_SCROLL_LINES);
93 } else {
94 app.vim_move_down();
95 }
96 true
97 }
98 _ => false,
99 }
100 }
101
102 fn cursor_has_previous_logical_line(app: &App) -> bool {
103 let cursor_byte = byte_index_at_char(&app.input, app.cursor_position);
104 app.input[..cursor_byte].contains('\n')
105 }
106
107 fn cursor_has_next_logical_line(app: &App) -> bool {
108 let cursor_byte = byte_index_at_char(&app.input, app.cursor_position);
109 app.input[cursor_byte..].contains('\n')
110 }
111
112 fn byte_index_at_char(text: &str, char_index: usize) -> usize {
113 if char_index == 0 {
114 return 0;
115 }
116 text.char_indices()
117 .nth(char_index)
118 .map(|(idx, _)| idx)
119 .unwrap_or(text.len())
120 }
121
122 pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool {
123 modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT)
124 }
125
126 /// On macOS, map `SUPER` (Cmd ⌘) to `CONTROL` when `CONTROL` is not already
127 /// set, so that terminal emulators that don't pass Ctrl faithfully still work.
128 /// On all other platforms this is a no-op.
129 #[cfg(target_os = "macos")]
130 pub(crate) fn normalize_macos_modifiers(modifiers: KeyModifiers) -> KeyModifiers {
131 // Strip SUPER and add CONTROL so that exact modifier equality checks
132 // (e.g. `modifiers == KeyModifiers::CONTROL` in Ctrl+G/Ctrl+S stashing) work
133 // correctly after normalization.
134 if modifiers.contains(KeyModifiers::SUPER) {
135 (modifiers - KeyModifiers::SUPER) | KeyModifiers::CONTROL
136 } else {
137 modifiers
138 }
139 }
140
141 #[cfg(not(target_os = "macos"))]
142 pub(crate) fn normalize_macos_modifiers(modifiers: KeyModifiers) -> KeyModifiers {
143 modifiers
144 }
145
146 pub(crate) fn handle_composer_alt_word_motion_key(app: &mut App, key: KeyEvent) -> bool {
147 if !key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::CONTROL) {
148 return false;
149 }
150
151 match key.code {
152 KeyCode::Char('f') | KeyCode::Char('F') => {
153 app.clear_selection();
154 app.move_cursor_word_forward();
155 true
156 }
157 KeyCode::Char('b') | KeyCode::Char('B') => {
158 app.clear_selection();
159 app.move_cursor_word_backward();
160 true
161 }
162 _ => false,
163 }
164 }
165
166 pub(crate) fn is_composer_newline_key(key: KeyEvent) -> bool {
167 match key.code {
168 KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL),
169 KeyCode::Enter => {
170 key.modifiers.contains(KeyModifiers::ALT)
171 || (key.modifiers.contains(KeyModifiers::SHIFT)
172 && !key.modifiers.contains(KeyModifiers::CONTROL))
173 }
174 _ => false,
175 }
176 }
177
178 pub(crate) fn is_forced_submit_key(key: KeyEvent) -> bool {
179 matches!(
180 composer_submit_chord(key),
181 Some(ComposerSubmitChord::CtrlEnter)
182 )
183 }
184
185 pub(crate) fn composer_submit_chord(key: KeyEvent) -> Option<ComposerSubmitChord> {
186 if !matches!(key.code, KeyCode::Enter) {
187 return None;
188 }
189 if key.modifiers.contains(KeyModifiers::ALT)
190 || (key.modifiers.contains(KeyModifiers::SHIFT)
191 && !key.modifiers.contains(KeyModifiers::CONTROL))
192 {
193 return None;
194 }
195 if key.modifiers.contains(KeyModifiers::CONTROL) {
196 Some(ComposerSubmitChord::CtrlEnter)
197 } else if key.modifiers == KeyModifiers::NONE {
198 Some(ComposerSubmitChord::Enter)
199 } else {
200 None
201 }
202 }
203
204 pub(crate) fn handle_history_search_key(app: &mut App, key: KeyEvent) {
205 match key.code {
206 KeyCode::Enter => {
207 let _ = app.accept_history_search();
208 }
209 KeyCode::Esc => {
210 app.cancel_history_search();
211 }
212 KeyCode::Char('c') | KeyCode::Char('C')
213 if key.modifiers.contains(KeyModifiers::CONTROL) =>
214 {
215 app.cancel_history_search();
216 }
217 KeyCode::Backspace => {
218 app.history_search_backspace();
219 }
220 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
221 while app
222 .history_search_query()
223 .is_some_and(|query| !query.is_empty())
224 {
225 app.history_search_backspace();
226 }
227 }
228 KeyCode::Up => {
229 app.history_search_select_previous();
230 }
231 KeyCode::Down => {
232 app.history_search_select_next();
233 }
234 KeyCode::Char(ch)
235 if key.modifiers.is_empty()
236 || key.modifiers == KeyModifiers::SHIFT
237 || key.modifiers == KeyModifiers::NONE =>
238 {
239 app.history_search_insert_char(ch);
240 }
241 _ => {}
242 }
243 }
244
244 lines RUST