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