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