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