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