Skip to main content

dot/tui/input/
mod.rs

1mod modes;
2mod mouse;
3mod popups;
4
5use std::path::Path;
6use std::time::{Duration, Instant};
7
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10use crate::tui::app::{App, AppMode};
11
12pub use mouse::handle_mouse;
13
14fn path_exists(path: &str) -> bool {
15    let resolved = if path.starts_with('~') {
16        std::env::var("HOME")
17            .map(|h| path.replacen('~', &h, 1))
18            .unwrap_or_else(|_| path.to_string())
19    } else {
20        path.to_string()
21    };
22    Path::new(&resolved).exists()
23}
24
25pub enum InputAction {
26    AnswerQuestion(String),
27    AnswerPermission(String),
28    None,
29    SendMessage(String),
30    Quit,
31    CancelStream,
32    ScrollUp(u32),
33    ScrollDown(u32),
34    ScrollToTop,
35    ScrollToBottom,
36    ClearConversation,
37    NewConversation,
38    OpenModelSelector,
39    OpenAgentSelector,
40    ToggleAgent,
41    OpenThinkingSelector,
42    OpenSessionSelector,
43    SelectModel {
44        provider: String,
45        model: String,
46    },
47    SelectAgent {
48        name: String,
49    },
50    ResumeSession {
51        id: String,
52    },
53    SetThinkingLevel(u32),
54    ToggleThinking,
55    CycleThinkingLevel,
56    TruncateToMessage(usize),
57    ForkFromMessage(usize),
58    RevertToMessage(usize),
59    CopyMessage(usize),
60    LoadSkill {
61        name: String,
62    },
63    RunCustomCommand {
64        name: String,
65        args: String,
66    },
67    OpenRenamePopup,
68    RenameSession(String),
69    ExportSession(Option<String>),
70    OpenExternalEditor,
71    OpenLoginPopup,
72    LoginSubmitApiKey {
73        provider: String,
74        key: String,
75    },
76    LoginOAuth {
77        provider: String,
78        create_key: bool,
79        code: String,
80        verifier: String,
81    },
82    AskAside {
83        question: String,
84    },
85}
86
87enum PasteItem {
88    Path(String),
89    Plain(String),
90}
91
92pub fn handle_paste(app: &mut App, text: String) -> InputAction {
93    if app.login_popup.visible {
94        let trimmed = text.trim().to_string();
95        if !trimmed.is_empty() {
96            match app.login_popup.step {
97                crate::tui::widgets::LoginStep::OAuthWaiting => {
98                    app.login_popup.code_input.push_str(&trimmed);
99                }
100                crate::tui::widgets::LoginStep::EnterApiKey => {
101                    app.login_popup.key_input.push_str(&trimmed);
102                }
103                _ => {}
104            }
105        }
106        return InputAction::None;
107    }
108
109    if app.vim_mode && app.mode != AppMode::Insert {
110        return InputAction::None;
111    }
112
113    let trimmed = text.trim_end_matches('\n');
114    if trimmed.is_empty() {
115        return InputAction::None;
116    }
117
118    let lines: Vec<&str> = trimmed
119        .split('\n')
120        .map(|s| s.trim())
121        .filter(|s| !s.is_empty())
122        .collect();
123
124    let mut items: Vec<PasteItem> = Vec::new();
125    for line in &lines {
126        if let Some(path) = crate::tui::app::normalize_paste_path(line)
127            && path_exists(&path)
128        {
129            items.push(PasteItem::Path(path));
130            continue;
131        }
132        items.push(PasteItem::Plain((*line).to_string()));
133    }
134
135    let mut plain_buf: Vec<String> = Vec::new();
136    for item in items {
137        match item {
138            PasteItem::Path(path) => {
139                if !plain_buf.is_empty() {
140                    app.handle_paste(plain_buf.join("\n"));
141                    plain_buf.clear();
142                }
143                if crate::tui::app::is_image_path(&path) {
144                    if let Err(e) = app.add_image_attachment(&path) {
145                        app.status_message = Some(crate::tui::app::StatusMessage::error(e));
146                    }
147                } else {
148                    app.insert_file_reference(&path);
149                }
150            }
151            PasteItem::Plain(s) => plain_buf.push(s),
152        }
153    }
154    if !plain_buf.is_empty() {
155        app.handle_paste(plain_buf.join("\n"));
156    }
157
158    InputAction::None
159}
160
161pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction {
162    if app.selection.anchor.is_some() {
163        app.selection.clear();
164    }
165
166    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
167        if app.input_selection_range().is_some() {
168            if let Some(text) = app.copy_input_selection() {
169                crate::tui::app::copy_to_clipboard(&text);
170            }
171            return InputAction::None;
172        }
173        if app.model_selector.visible {
174            app.model_selector.close();
175            return InputAction::None;
176        }
177        if app.agent_selector.visible {
178            app.agent_selector.close();
179            return InputAction::None;
180        }
181        if app.command_palette.visible {
182            app.command_palette.close();
183            return InputAction::None;
184        }
185        if app.file_picker.visible {
186            app.file_picker.close();
187            return InputAction::None;
188        }
189        if app.thinking_selector.visible {
190            app.thinking_selector.close();
191            return InputAction::None;
192        }
193        if app.session_selector.visible {
194            app.session_selector.close();
195            return InputAction::None;
196        }
197        if app.help_popup.visible {
198            app.help_popup.close();
199            return InputAction::None;
200        }
201        if app.is_streaming {
202            return InputAction::CancelStream;
203        }
204        if !app.input.is_empty() || !app.attachments.is_empty() {
205            app.input.clear();
206            app.cursor_pos = 0;
207            app.paste_blocks.clear();
208            app.attachments.clear();
209            app.clear_input_selection();
210            return InputAction::None;
211        }
212        return InputAction::Quit;
213    }
214
215    if key.code == KeyCode::Esc && app.is_streaming {
216        let now = Instant::now();
217        if let Some(hint_until) = app.esc_hint_until
218            && now < hint_until
219        {
220            app.esc_hint_until = None;
221            app.last_escape_time = None;
222            return InputAction::CancelStream;
223        }
224        app.esc_hint_until = Some(now + Duration::from_secs(3));
225        app.last_escape_time = Some(now);
226        return InputAction::None;
227    }
228
229    if app.model_selector.visible {
230        return popups::handle_model_selector(app, key);
231    }
232
233    if app.agent_selector.visible {
234        return popups::handle_agent_selector(app, key);
235    }
236
237    if app.thinking_selector.visible {
238        return popups::handle_thinking_selector(app, key);
239    }
240
241    if app.session_selector.visible {
242        return popups::handle_session_selector(app, key);
243    }
244
245    if app.help_popup.visible {
246        if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
247            app.help_popup.close();
248        }
249        return InputAction::None;
250    }
251
252    if app.aside_popup.visible {
253        return popups::handle_aside_popup(app, key);
254    }
255
256    if app.rename_visible {
257        return popups::handle_rename_popup(app, key);
258    }
259
260    if app.pending_question.is_some() {
261        return popups::handle_question_popup(app, key);
262    }
263
264    if app.pending_permission.is_some() {
265        return popups::handle_permission_popup(app, key);
266    }
267
268    if app.welcome_screen.visible {
269        return popups::handle_welcome_screen(app, key);
270    }
271
272    if app.login_popup.visible {
273        return popups::handle_login_popup(app, key);
274    }
275
276    if app.context_menu.visible {
277        return popups::handle_context_menu(app, key);
278    }
279
280    if app.command_palette.visible {
281        return popups::handle_command_palette(app, key);
282    }
283
284    if app.file_picker.visible {
285        return popups::handle_file_picker(app, key);
286    }
287
288    if key.modifiers.contains(KeyModifiers::CONTROL)
289        && key.code == KeyCode::Char('e')
290        && (!app.vim_mode || app.mode == AppMode::Insert)
291    {
292        return InputAction::OpenExternalEditor;
293    }
294
295    if app.vim_mode {
296        match app.mode {
297            AppMode::Normal => modes::handle_normal(app, key),
298            AppMode::Insert => modes::handle_insert(app, key),
299        }
300    } else {
301        modes::handle_simple(app, key)
302    }
303}