Skip to main content

wisp/app/
input.rs

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        // Exit handling must precede route and overlay dispatch so no modal can swallow it.
41        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    /// Routes a keystroke the conversation owns. Routes and overlays are handled by
88    /// [`Self::on_ui_event`] before this is reached.
89    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    /// Acts on the little the composer's overlays cannot do for themselves.
99    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            // Up/Down fall through to prompt history once the cursor is on the
179            // first or last line of the composer.
180            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    /// Builtin commands run as soon as they are accepted; commands taking input
204    /// leave the composer ready for it.
205    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    /// Only the bare composer works without mouse reporting; every other
246    /// route or overlay has scrollable or clickable content.
247    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
260/// Normalizes terminal key delivery once at the application boundary. Repeats
261/// have press semantics, releases never reach feature routing, and all other
262/// event kinds pass through unchanged. Features may then apply their own exact
263/// versus contained modifier policy to the normalized key.
264fn 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}