1use super::config::cycle_reasoning_option;
2use super::{App, ExitState, Overlay, Route};
3use crate::command::{AgentCommand, Command, GitReviewCommand};
4use crate::renderer::DrawContext;
5use crate::screens::git_diff::GitDiffScreen;
6use crate::session::WorkspaceAccess;
7use crate::session::session_config_view::LocalConfigView;
8use crate::settings::KeyAction;
9use crate::surfaces::composer::ComposerOutcome;
10use crate::surfaces::dropped_files::parse_dropped_file_paths;
11use crate::view::input::{MouseAction, UiEvent};
12use crate::surfaces::picker::CommandEntry;
13use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
14use ratatui::buffer::Buffer;
15use ratatui::layout::{Position, Rect};
16use std::time::{Duration, Instant};
17
18pub(super) const CTRL_C_CONFIRM_WINDOW: Duration = Duration::from_secs(1);
19
20impl App {
21 pub fn on_terminal_event(&mut self, event: Event) {
22 let event = match event {
23 Event::Key(key) => UiEvent::Key(key),
24 Event::Paste(text) => UiEvent::Paste(text),
25 Event::Mouse(mouse) => {
26 let Some(action) = MouseAction::from_event(mouse.kind) else { return };
27 UiEvent::Mouse(action, (mouse.column, mouse.row))
28 }
29 Event::FocusLost => {
30 self.swallow_next_click = true;
31 return;
32 }
33 Event::Resize(width, _) => {
34 self.composer.on_resize(width);
35 return;
36 }
37 Event::FocusGained => return,
38 };
39 if self.is_refocusing_click(&event) {
40 return;
41 }
42 self.on_ui_event(event);
43 }
44
45 fn is_refocusing_click(&mut self, event: &UiEvent) -> bool {
46 match event {
47 UiEvent::Mouse(MouseAction::Click, _) => std::mem::take(&mut self.swallow_next_click),
48 UiEvent::Key(_) | UiEvent::Paste(_) => {
49 self.swallow_next_click = false;
50 false
51 }
52 UiEvent::Mouse(..) => false,
53 }
54 }
55
56 fn on_ui_event(&mut self, event: UiEvent) {
57 let Some(event) = normalize_ui_event(event) else {
58 return;
59 };
60 if let UiEvent::Key(key) = &event
62 && self.ui.keybindings.matches(KeyAction::Exit, *key)
63 {
64 self.arm_or_confirm_exit();
65 return;
66 }
67
68 if let Some(overlay) = self.overlay.as_mut() {
69 let outputs = match overlay {
70 Overlay::Settings(overlay) => overlay.on_ui_event(&event),
71 Overlay::Sessions(picker) => picker.on_ui_event(&event),
72 Overlay::Workspaces(picker) => picker.on_ui_event(&event),
73 Overlay::Elicitation(modal) => modal.on_ui_event(&event),
74 };
75 self.dispatch_outputs(outputs);
76 return;
77 }
78 if let UiEvent::Key(key) = &event
79 && self.ui.keybindings.matches(KeyAction::ToggleGitDiff, *key)
80 && matches!(&self.route, Route::GitReview(screen) if screen.is_browsing())
81 {
82 self.close_active();
83 return;
84 }
85 let outputs = match &mut self.route {
86 Route::GitReview(screen) => screen.on_ui_event(&event),
87 Route::ArtifactReview(screen) => screen.on_ui_event(&event),
88 Route::Conversation => {
89 return match &event {
90 UiEvent::Key(key) => self.dispatch_key(*key),
91 UiEvent::Paste(text) => self.on_composer_paste(text),
92 UiEvent::Mouse(..) => self.composer.on_overlay_mouse(&event),
93 };
94 }
95 };
96 self.dispatch_outputs(outputs);
97 }
98
99 fn arm_or_confirm_exit(&mut self) {
100 if self.exit_state.is_confirming() {
101 self.exit_state = ExitState::Exiting;
102 } else {
103 self.composer.clear();
104 self.exit_state = ExitState::Confirming(Instant::now());
105 }
106 }
107
108 fn dispatch_key(&mut self, key: KeyEvent) {
111 if let Some(outcome) = self.composer.on_prompt_search_key(key) {
112 self.apply_composer_outcome(outcome);
113 return;
114 }
115
116 self.on_composer_key(key);
117 }
118
119 fn apply_composer_outcome(&mut self, outcome: ComposerOutcome) {
121 match outcome {
122 ComposerOutcome::Handled => {}
123 ComposerOutcome::AcceptedCommand(command) => self.run_accepted_command(&command),
124 ComposerOutcome::Search(query) => self.send_prompt_search_query(query),
125 }
126 }
127
128 fn on_composer_key(&mut self, key: KeyEvent) {
129 if self.ui.keybindings.matches(KeyAction::OpenPromptSearch, key)
130 && self.session.capabilities().prompt_search
131 && !self.composer.has_completion()
132 {
133 self.composer.open_prompt_search();
134 return;
135 }
136
137 if self.ui.keybindings.matches(KeyAction::ToggleGitDiff, key) {
138 let screen = GitDiffScreen::new();
139 self.open_route(Route::GitReview(Box::new(screen)));
140 self.queue(Command::GitReview(GitReviewCommand::Open {
141 session_id: self.session.session_id().0.to_string(),
142 }));
143 return;
144 }
145
146 if key.code == KeyCode::Enter && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT)
147 || key.code == KeyCode::Char('j') && key.modifiers.contains(KeyModifiers::CONTROL)
148 {
149 self.composer.insert_newline();
150 return;
151 }
152
153 if let Some(outcome) = self.composer.on_completion_key(key) {
154 self.apply_composer_outcome(outcome);
155 return;
156 }
157
158 if self.ui.keybindings.matches(KeyAction::CycleReasoning, key) {
159 self.apply_config_cycle(cycle_reasoning_option(self.session.config_options()));
160 return;
161 }
162
163 if self.ui.keybindings.matches(KeyAction::CycleMode, key) {
164 let view = LocalConfigView::new(self.session.config_options());
165 let next = view.next_mode().map(|(id, value)| (id.to_string(), value.to_string()));
166 self.apply_config_cycle(next);
167 return;
168 }
169
170 if self.ui.keybindings.matches(KeyAction::Submit, key) {
171 self.submit();
172 return;
173 }
174
175 if self.ui.keybindings.matches(KeyAction::Cancel, key) {
176 if self.waiting_for_response() {
177 self.queue(Command::Agent(AgentCommand::Cancel { session_id: self.session.session_id().clone() }));
178 }
179 return;
180 }
181
182 if let KeyCode::Char(character) = key.code {
183 let opens_command_picker =
184 self.ui.keybindings.matches(KeyAction::OpenCommandPicker, key) && self.composer.text().is_empty();
185 if opens_command_picker || self.ui.keybindings.matches(KeyAction::OpenFilePicker, key) {
186 self.composer.insert_char(character);
187 if opens_command_picker {
188 self.composer.open_command_picker(self.available_commands.clone());
189 } else if self.session.workspace_access() == WorkspaceAccess::Remote {
190 self.notify("File picker is unavailable for remote workspaces");
191 } else {
192 let command = self.composer.open_file_picker(self.session.working_dir());
193 self.queue(Command::Filesystem(command));
194 }
195 return;
196 }
197 }
198
199 match key.code {
200 KeyCode::Up if !self.composer.move_up() => {
203 self.composer.recall_previous();
204 }
205 KeyCode::Down if !self.composer.move_down() => {
206 self.composer.recall_next();
207 }
208 _ => {
209 self.composer.apply_edit_key(key);
210 }
211 }
212 }
213
214 fn apply_config_cycle(&mut self, next: Option<(String, String)>) {
215 if let Some((id, value)) = next {
216 self.set_config_option(&id, &value);
217 self.session.update_config_option_value(&id, &value);
218 }
219 }
220
221 fn run_accepted_command(&mut self, command: &CommandEntry) {
224 if command.builtin {
225 self.dispatch_builtin_command(command);
226 } else if command.has_input {
227 self.composer.insert_char(' ');
228 } else {
229 self.submit();
230 }
231 }
232
233 fn on_composer_paste(&mut self, text: &str) {
234 if let Some(outcome) = self.composer.on_prompt_search_paste(text) {
235 self.apply_composer_outcome(outcome);
236 return;
237 }
238 let added = self.session.workspace_access() == WorkspaceAccess::Local
239 && parse_dropped_file_paths(text).is_some_and(|paths| self.composer.add_dropped_media(paths));
240 if !added {
241 self.composer.insert_paste(text);
242 }
243 self.composer.refresh_overlay_query();
244 }
245
246 pub fn render_route(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
247 match &mut self.route {
248 Route::Conversation => None,
249 Route::GitReview(screen) => screen.render(area, buf, cx),
250 Route::ArtifactReview(screen) => screen.render(area, buf, cx),
251 }
252 }
253
254 pub fn render_overlay(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
255 match self.overlay.as_mut() {
256 Some(Overlay::Settings(overlay)) => overlay.render(area, buf, cx),
257 Some(Overlay::Sessions(picker)) => picker.render(area, buf, cx),
258 Some(Overlay::Workspaces(picker)) => picker.render(area, buf, cx),
259 Some(Overlay::Elicitation(modal)) => modal.render(area, buf, cx),
260 None => None,
261 }
262 }
263
264 pub fn needs_mouse_capture(&self) -> bool {
267 match self.overlay.as_ref() {
268 Some(Overlay::Elicitation(modal)) => modal.needs_mouse_capture(),
269 Some(Overlay::Settings(overlay)) => overlay.needs_mouse_capture(),
270 Some(_) => true,
271 None => match self.route {
272 Route::Conversation => self.composer.has_open_overlay(),
273 Route::GitReview(_) | Route::ArtifactReview(_) => true,
274 },
275 }
276 }
277}
278
279fn normalize_ui_event(event: UiEvent) -> Option<UiEvent> {
284 match event {
285 UiEvent::Key(mut key) => match key.kind {
286 KeyEventKind::Press | KeyEventKind::Repeat => {
287 key.kind = KeyEventKind::Press;
288 Some(UiEvent::Key(key))
289 }
290 KeyEventKind::Release => None,
291 },
292 event => Some(event),
293 }
294}