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, GitReviewCommand};
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::FocusLost => {
29                self.swallow_next_click = true;
30                return;
31            }
32            Event::Resize(width, _) => {
33                self.composer.on_resize(width);
34                return;
35            }
36            Event::FocusGained => return,
37        };
38        if self.is_refocusing_click(&event) {
39            return;
40        }
41        self.on_ui_event(event);
42    }
43
44    fn is_refocusing_click(&mut self, event: &UiEvent) -> bool {
45        match event {
46            UiEvent::Mouse(MouseAction::Click, _) => std::mem::take(&mut self.swallow_next_click),
47            UiEvent::Key(_) | UiEvent::Paste(_) => {
48                self.swallow_next_click = false;
49                false
50            }
51            UiEvent::Mouse(..) => false,
52        }
53    }
54
55    fn on_ui_event(&mut self, event: UiEvent) {
56        let Some(event) = normalize_ui_event(event) else {
57            return;
58        };
59        // Exit handling must precede route and overlay dispatch so no modal can swallow it.
60        if let UiEvent::Key(key) = &event
61            && self.ui.keybindings.exit.matches(*key)
62        {
63            self.arm_or_confirm_exit();
64            return;
65        }
66
67        if let Some(overlay) = self.overlay.as_mut() {
68            let actions = match overlay {
69                Overlay::Settings(overlay) => {
70                    overlay.on_ui_event(event).into_iter().map(RootOutput::Settings).collect()
71                }
72                Overlay::Sessions(picker) => picker.on_ui_event(event).into_iter().map(RootOutput::Session).collect(),
73                Overlay::Workspaces(picker) => {
74                    picker.on_ui_event(event).into_iter().map(RootOutput::Workspace).collect()
75                }
76                Overlay::Elicitation(modal) => {
77                    modal.on_ui_event(event).into_iter().map(RootOutput::Elicitation).collect()
78                }
79            };
80            self.dispatch_outputs(actions);
81            return;
82        }
83        if let UiEvent::Key(key) = &event
84            && self.ui.keybindings.toggle_git_diff.matches(*key)
85            && matches!(&self.route, Route::GitReview(screen) if screen.is_browsing())
86        {
87            self.close_active();
88            return;
89        }
90        let actions: Vec<RootOutput> = match &mut self.route {
91            Route::GitReview(screen) => screen.on_ui_event(event).into_iter().map(RootOutput::GitReview).collect(),
92            Route::ArtifactReview(screen) => {
93                screen.on_ui_event(event).into_iter().map(RootOutput::ArtifactReview).collect()
94            }
95            Route::Conversation => {
96                return match event {
97                    UiEvent::Key(key) => self.dispatch_key(key),
98                    UiEvent::Paste(text) => self.on_composer_paste(&text),
99                    UiEvent::Mouse(action, (_, row)) => self.composer.on_overlay_mouse(action, row),
100                };
101            }
102        };
103        self.dispatch_outputs(actions);
104    }
105
106    fn arm_or_confirm_exit(&mut self) {
107        if self.exit_state.is_confirming() {
108            self.exit_state = ExitState::Exiting;
109        } else {
110            self.composer.clear();
111            self.exit_state = ExitState::Confirming(Instant::now());
112        }
113    }
114
115    /// Routes a keystroke the conversation owns. Routes and overlays are handled by
116    /// [`Self::on_ui_event`] before this is reached.
117    fn dispatch_key(&mut self, key: KeyEvent) {
118        if let Some(outcome) = self.composer.on_prompt_search_key(key) {
119            self.apply_composer_outcome(outcome);
120            return;
121        }
122
123        self.on_composer_key(key);
124    }
125
126    /// Acts on the little the composer's overlays cannot do for themselves.
127    fn apply_composer_outcome(&mut self, outcome: ComposerOutcome) {
128        match outcome {
129            ComposerOutcome::Handled => {}
130            ComposerOutcome::AcceptedCommand(command) => self.run_accepted_command(&command),
131            ComposerOutcome::Search(query) => self.send_prompt_search_query(query),
132        }
133    }
134
135    fn on_composer_key(&mut self, key: KeyEvent) {
136        if self.ui.keybindings.open_prompt_search.matches(key)
137            && self.session.capabilities().prompt_search
138            && !self.composer.has_completion()
139        {
140            self.composer.open_prompt_search();
141            return;
142        }
143
144        if self.ui.keybindings.toggle_git_diff.matches(key) {
145            let screen = GitDiffScreen::new();
146            self.open_route(Route::GitReview(Box::new(screen)));
147            self.queue(Command::GitReview(GitReviewCommand::Open {
148                session_id: self.session.session_id().0.to_string(),
149            }));
150            return;
151        }
152
153        if key.code == KeyCode::Enter && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT)
154            || key.code == KeyCode::Char('j') && key.modifiers.contains(KeyModifiers::CONTROL)
155        {
156            self.composer.insert_newline();
157            return;
158        }
159
160        if let Some(outcome) = self.composer.on_completion_key(key) {
161            self.apply_composer_outcome(outcome);
162            return;
163        }
164
165        if self.ui.keybindings.cycle_reasoning.matches(key) {
166            self.apply_config_cycle(cycle_reasoning_option(self.session.config_options()));
167            return;
168        }
169
170        if self.ui.keybindings.cycle_mode.matches(key) {
171            let view = LocalConfigView::new(self.session.config_options());
172            let next = view.next_mode().map(|(id, value)| (id.to_string(), value.to_string()));
173            self.apply_config_cycle(next);
174            return;
175        }
176
177        if self.ui.keybindings.submit.matches(key) {
178            self.submit();
179            return;
180        }
181
182        if self.ui.keybindings.cancel.matches(key) {
183            if self.waiting_for_response() {
184                self.queue(Command::Agent(AgentCommand::Cancel { session_id: self.session.session_id().clone() }));
185            }
186            return;
187        }
188
189        if let KeyCode::Char(character) = key.code {
190            let opens_command_picker =
191                self.ui.keybindings.open_command_picker.matches(key) && self.composer.text().is_empty();
192            if opens_command_picker || self.ui.keybindings.open_file_picker.matches(key) {
193                self.composer.insert_char(character);
194                if opens_command_picker {
195                    self.composer.open_command_picker(self.available_commands.clone());
196                } else if self.session.workspace_access() == WorkspaceAccess::Remote {
197                    self.notify("File picker is unavailable for remote workspaces");
198                } else {
199                    let command = self.composer.open_file_picker(self.session.working_dir());
200                    self.queue(Command::Filesystem(command));
201                }
202                return;
203            }
204        }
205
206        match key.code {
207            KeyCode::Char(character) if !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
208                self.composer.insert_char(character);
209            }
210            // Up/Down fall through to prompt history once the cursor is on the
211            // first or last line of the composer.
212            KeyCode::Up if !self.composer.move_up() => {
213                self.composer.recall_previous();
214            }
215            KeyCode::Down if !self.composer.move_down() => {
216                self.composer.recall_next();
217            }
218            _ => {
219                self.composer.apply_edit_key(key);
220            }
221        }
222    }
223
224    fn apply_config_cycle(&mut self, next: Option<(String, String)>) {
225        if let Some((id, value)) = next {
226            self.set_config_option(&id, &value);
227            self.session.update_config_option_value(&id, &value);
228        }
229    }
230
231    /// Builtin commands run as soon as they are accepted; commands taking input
232    /// leave the composer ready for it.
233    fn run_accepted_command(&mut self, command: &CommandEntry) {
234        if command.builtin {
235            self.dispatch_builtin_command(command);
236        } else if command.has_input {
237            self.composer.insert_char(' ');
238        } else {
239            self.submit();
240        }
241    }
242
243    fn on_composer_paste(&mut self, text: &str) {
244        if let Some(outcome) = self.composer.on_prompt_search_paste(text) {
245            self.apply_composer_outcome(outcome);
246            return;
247        }
248        let added = self.session.workspace_access() == WorkspaceAccess::Local
249            && parse_dropped_file_paths(text).is_some_and(|paths| self.composer.add_dropped_media(paths));
250        if !added {
251            self.composer.insert_paste(text);
252        }
253        self.composer.refresh_overlay_query();
254    }
255
256    pub fn render_route(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
257        match &mut self.route {
258            Route::Conversation => None,
259            Route::GitReview(screen) => screen.render(area, buf, cx),
260            Route::ArtifactReview(screen) => screen.render(area, buf, cx),
261        }
262    }
263
264    pub fn render_overlay(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
265        match self.overlay.as_mut() {
266            Some(Overlay::Settings(overlay)) => overlay.render(area, buf, cx),
267            Some(Overlay::Sessions(picker)) => picker.render(area, buf, cx),
268            Some(Overlay::Workspaces(picker)) => picker.render(area, buf, cx),
269            Some(Overlay::Elicitation(modal)) => modal.render(area, buf, cx),
270            None => None,
271        }
272    }
273
274    /// Only the bare composer works without mouse reporting; every other
275    /// route or overlay has scrollable or clickable content.
276    pub fn needs_mouse_capture(&self) -> bool {
277        match self.overlay.as_ref() {
278            Some(Overlay::Elicitation(modal)) => modal.needs_mouse_capture(),
279            Some(Overlay::Settings(overlay)) => overlay.needs_mouse_capture(),
280            Some(_) => true,
281            None => match self.route {
282                Route::Conversation => self.composer.has_open_overlay(),
283                Route::GitReview(_) | Route::ArtifactReview(_) => true,
284            },
285        }
286    }
287}
288
289/// Normalizes terminal key delivery once at the application boundary. Repeats
290/// have press semantics, releases never reach feature routing, and all other
291/// event kinds pass through unchanged. Features may then apply their own exact
292/// versus contained modifier policy to the normalized key.
293fn normalize_ui_event(event: UiEvent) -> Option<UiEvent> {
294    match event {
295        UiEvent::Key(mut key) => match key.kind {
296            KeyEventKind::Press | KeyEventKind::Repeat => {
297                key.kind = KeyEventKind::Press;
298                Some(UiEvent::Key(key))
299            }
300            KeyEventKind::Release => None,
301        },
302        event => Some(event),
303    }
304}