Skip to main content

recursive/tui/
input_state.rs

1//! Input mode and prompt-input state for the Recursive TUI.
2//!
3//! Contains the multi-mode input machinery: [`InputMode`], the mutable
4//! [`PromptInputState`] buffer, double-press tracking for Esc/Ctrl+C, and
5//! history-ring management.
6
7use std::time::Instant;
8
9// ──────────────────────────────────────────────────────────────────────
10// Double-press tracker (Goal 147)
11// ──────────────────────────────────────────────────────────────────────
12
13/// Default window for double-press detection (Esc / Ctrl+C). The
14/// runtime can override this via the `RECURSIVE_TUI_DOUBLE_MS` env
15/// var — see [`double_press_window`].
16pub const DOUBLE_PRESS_WINDOW: std::time::Duration = std::time::Duration::from_millis(2000);
17
18/// Resolve the active double-press window. Reads
19/// `RECURSIVE_TUI_DOUBLE_MS` once per call (cheap; this is hit only on
20/// keypress) and falls back to [`DOUBLE_PRESS_WINDOW`] on parse
21/// failure.
22pub fn double_press_window() -> std::time::Duration {
23    std::env::var("RECURSIVE_TUI_DOUBLE_MS")
24        .ok()
25        .and_then(|raw| raw.parse::<u64>().ok())
26        .map(std::time::Duration::from_millis)
27        .unwrap_or(DOUBLE_PRESS_WINDOW)
28}
29
30/// Tracks when the user last pressed Esc / Ctrl+C. Goal 147 maps a
31/// "double press within window" to a stronger action (real exit on
32/// the second Ctrl+C) while a single press triggers the
33/// context-dependent path (interrupt / clear buffer / pop modal).
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct DoublePressTracker {
36    pub last_esc_at: Option<Instant>,
37    pub last_ctrl_c_at: Option<Instant>,
38}
39
40// ──────────────────────────────────────────────────────────────────────
41// InputMode (Goal 145)
42// ──────────────────────────────────────────────────────────────────────
43
44/// Which input mode the PromptInput is currently in.
45///
46/// Goal-145: the input box is mode-aware, with auto-detection from the
47/// first character (`!`/`#`/`/`) and explicit cycling via Shift+Tab.
48///
49/// Goal-158: added `AtFile` mode — triggered by typing `@` in Prompt
50/// mode, showing a file-completion popup. The query and suggestion
51/// list are stored separately in [`App`] (`atfile_query`,
52/// `atfile_suggestions`, `atfile_selected`) so this enum stays `Copy`.
53///
54/// Goal-160: added `HistorySearch` mode — triggered by `Ctrl+R` in
55/// Prompt mode, showing a fuzzy-search popup over submission history.
56/// Search state lives in `App` (`hsearch_query`, `hsearch_matches`,
57/// `hsearch_selected`) so this enum stays `Copy`.
58#[derive(Clone, Copy, PartialEq, Eq, Debug)]
59pub enum InputMode {
60    /// Default mode — submit goes to the LLM as a user message.
61    Prompt,
62    /// `!`-prefixed; submit dispatches `run_shell` directly,
63    /// bypassing the LLM and the runtime transcript.
64    Bash,
65    /// `#`-prefixed; submit appends a `System` block locally only,
66    /// nothing is sent to the backend.
67    Note,
68    /// `/`-prefixed; submit will eventually invoke a slash-command
69    /// (Goal 146). For Step 3 we render a placeholder System block.
70    Command,
71    /// `@`-triggered (Goal 158): shows a file-path completion popup.
72    /// The completion query and candidates live in the parent [`App`].
73    AtFile,
74    /// `Ctrl+R`-triggered (Goal 160): shows a fuzzy history-search
75    /// popup. Search state lives in the parent [`App`].
76    HistorySearch,
77}
78
79impl InputMode {
80    /// Indicator character for the left of the input box.
81    pub fn indicator(self) -> char {
82        match self {
83            InputMode::Prompt | InputMode::AtFile | InputMode::HistorySearch => '❯',
84            InputMode::Bash => '!',
85            InputMode::Note => '#',
86            InputMode::Command => '/',
87        }
88    }
89
90    /// Mode prefix used when storing entries in the history ring so
91    /// that recalling them later restores the originating mode.
92    pub fn history_prefix(self) -> &'static str {
93        match self {
94            InputMode::Prompt | InputMode::AtFile | InputMode::HistorySearch => "",
95            InputMode::Bash => "!",
96            InputMode::Note => "#",
97            InputMode::Command => "/",
98        }
99    }
100
101    /// Cycle Prompt → Bash → Note → Prompt. Skips `Command`, `AtFile`,
102    /// and `HistorySearch` because those can only be reached by typing
103    /// their trigger — matches fake-cc's behaviour.
104    pub fn cycle_next(self) -> InputMode {
105        match self {
106            InputMode::Prompt => InputMode::Bash,
107            InputMode::Bash => InputMode::Note,
108            InputMode::Note => InputMode::Prompt,
109            InputMode::Command | InputMode::AtFile | InputMode::HistorySearch => InputMode::Prompt,
110        }
111    }
112}
113
114/// Maximum number of history entries to retain in the ringbuffer.
115pub const HISTORY_CAPACITY: usize = 200;
116
117/// Mutable state of the multi-mode prompt input.
118///
119/// Owns the editing buffer, byte-cursor, in-session history, and a
120/// stash slot for the user's draft when they walk back through
121/// history. Rendering is in [`crate::tui::ui::input`].
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct PromptInputState {
124    pub mode: InputMode,
125    pub buffer: String,
126    /// Byte offset into [`buffer`]. Always at a char boundary.
127    pub cursor: usize,
128    /// Submitted entries, oldest first. Capped at
129    /// [`HISTORY_CAPACITY`].
130    pub history: Vec<String>,
131    /// Position when navigating history. `None` means "live draft".
132    pub history_idx: Option<usize>,
133    /// Stash slot: when the user starts walking history, the current
134    /// buffer is preserved here and restored when they walk past the
135    /// end.
136    pub draft: String,
137    /// Stash for the current mode while walking history. Restored
138    /// alongside `draft`.
139    pub draft_mode: InputMode,
140}
141
142impl Default for PromptInputState {
143    fn default() -> Self {
144        Self {
145            mode: InputMode::Prompt,
146            buffer: String::new(),
147            cursor: 0,
148            history: Vec::new(),
149            history_idx: None,
150            draft: String::new(),
151            draft_mode: InputMode::Prompt,
152        }
153    }
154}
155
156impl PromptInputState {
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Insert a single character at the cursor. Updates `cursor` to
162    /// stay just past the inserted char.
163    pub fn insert_char(&mut self, ch: char) {
164        self.buffer.insert(self.cursor, ch);
165        self.cursor += ch.len_utf8();
166        self.history_idx = None;
167    }
168
169    /// Delete the character to the left of the cursor (Backspace).
170    /// Returns `true` if a char was deleted.
171    pub fn backspace(&mut self) -> bool {
172        if self.cursor == 0 {
173            return false;
174        }
175        let prev = self.buffer[..self.cursor]
176            .char_indices()
177            .next_back()
178            .map(|(i, _)| i)
179            .unwrap_or(0);
180        self.buffer.drain(prev..self.cursor);
181        self.cursor = prev;
182        self.history_idx = None;
183        true
184    }
185
186    /// Delete the character at the cursor (Delete key).
187    pub fn delete_forward(&mut self) {
188        if self.cursor >= self.buffer.len() {
189            return;
190        }
191        let after = self.buffer[self.cursor..]
192            .char_indices()
193            .nth(1)
194            .map(|(i, _)| self.cursor + i)
195            .unwrap_or(self.buffer.len());
196        self.buffer.drain(self.cursor..after);
197        self.history_idx = None;
198    }
199
200    /// Move cursor one char left.
201    pub fn move_left(&mut self) {
202        if self.cursor == 0 {
203            return;
204        }
205        self.cursor = self.buffer[..self.cursor]
206            .char_indices()
207            .next_back()
208            .map(|(i, _)| i)
209            .unwrap_or(0);
210    }
211
212    /// Move cursor one char right.
213    pub fn move_right(&mut self) {
214        if self.cursor >= self.buffer.len() {
215            return;
216        }
217        let step = self.buffer[self.cursor..]
218            .chars()
219            .next()
220            .map(|c| c.len_utf8())
221            .unwrap_or(0);
222        self.cursor = (self.cursor + step).min(self.buffer.len());
223    }
224
225    /// Move to start of the current visual line (delimited by `\n`).
226    pub fn move_home(&mut self) {
227        self.cursor = self.buffer[..self.cursor]
228            .rfind('\n')
229            .map(|i| i + 1)
230            .unwrap_or(0);
231    }
232
233    /// Move to end of the current visual line.
234    pub fn move_end(&mut self) {
235        self.cursor = self.buffer[self.cursor..]
236            .find('\n')
237            .map(|i| self.cursor + i)
238            .unwrap_or(self.buffer.len());
239    }
240
241    /// Move cursor to the same column on the previous visual line.
242    /// No-op when the cursor is already on the first line. If the
243    /// previous line is shorter than the current column, the cursor
244    /// lands at the end of the previous line (emacs `previous-line`
245    /// semantics).
246    pub fn move_prev_line(&mut self) {
247        if self.cursor_on_first_line() {
248            return;
249        }
250        // Start of the line the cursor is currently on.
251        let cur_line_start = self.buffer[..self.cursor]
252            .rfind('\n')
253            .map(|i| i + 1)
254            .unwrap_or(0);
255        let col = self.cursor - cur_line_start;
256        // End of the previous line (the `\n` just before
257        // `cur_line_start` minus one).
258        let prev_line_end = cur_line_start - 1;
259        let prev_line_start = self.buffer[..prev_line_end]
260            .rfind('\n')
261            .map(|i| i + 1)
262            .unwrap_or(0);
263        let prev_line_len = prev_line_end - prev_line_start;
264        self.cursor = prev_line_start + col.min(prev_line_len);
265    }
266
267    /// Move cursor to the same column on the next visual line.
268    /// No-op when the cursor is already on the last line. If the
269    /// next line is shorter than the current column, the cursor
270    /// lands at the end of the next line (emacs `next-line`
271    /// semantics).
272    pub fn move_next_line(&mut self) {
273        if self.cursor_on_last_line() {
274            return;
275        }
276        let cur_line_start = self.buffer[..self.cursor]
277            .rfind('\n')
278            .map(|i| i + 1)
279            .unwrap_or(0);
280        let col = self.cursor - cur_line_start;
281        // End of the current line (where its `\n` lives).
282        let cur_line_end = self.buffer[self.cursor..]
283            .find('\n')
284            .map(|i| self.cursor + i)
285            .unwrap_or(self.buffer.len());
286        // Start of the next line is one past the `\n`.
287        let next_line_start = cur_line_end + 1;
288        if next_line_start > self.buffer.len() {
289            return;
290        }
291        let next_line_end = self.buffer[next_line_start..]
292            .find('\n')
293            .map(|i| next_line_start + i)
294            .unwrap_or(self.buffer.len());
295        let next_line_len = next_line_end - next_line_start;
296        self.cursor = next_line_start + col.min(next_line_len);
297    }
298
299    /// True when the cursor sits on the **first** visual line.
300    pub fn cursor_on_first_line(&self) -> bool {
301        !self.buffer[..self.cursor].contains('\n')
302    }
303
304    /// True when the cursor sits on the **last** visual line.
305    pub fn cursor_on_last_line(&self) -> bool {
306        !self.buffer[self.cursor..].contains('\n')
307    }
308
309    /// Begin a history walk: stash the current buffer + mode and
310    /// load the last entry. No-op when history is empty.
311    fn enter_history_walk(&mut self) {
312        if self.history_idx.is_none() {
313            self.draft = self.buffer.clone();
314            self.draft_mode = self.mode;
315            self.history_idx = Some(self.history.len());
316        }
317    }
318
319    /// Walk history one step back (older). Returns `true` if state
320    /// changed.
321    pub fn history_prev(&mut self) -> bool {
322        if self.history.is_empty() {
323            return false;
324        }
325        self.enter_history_walk();
326        let idx = self.history_idx.unwrap_or(self.history.len());
327        if idx == 0 {
328            return false;
329        }
330        let new_idx = idx - 1;
331        self.load_history(new_idx);
332        true
333    }
334
335    /// Walk history one step forward (newer). Restores the draft
336    /// when stepping past the most-recent entry. Returns `true` if
337    /// state changed.
338    pub fn history_next(&mut self) -> bool {
339        let Some(idx) = self.history_idx else {
340            return false;
341        };
342        let next = idx + 1;
343        if next >= self.history.len() {
344            // Past the newest entry: restore live draft.
345            self.buffer = std::mem::take(&mut self.draft);
346            self.cursor = self.buffer.len();
347            self.mode = self.draft_mode;
348            self.history_idx = None;
349        } else {
350            self.load_history(next);
351        }
352        true
353    }
354
355    fn load_history(&mut self, idx: usize) {
356        let raw = &self.history[idx];
357        let (mode, body) = strip_history_prefix(raw);
358        self.mode = mode;
359        self.buffer = body.to_string();
360        self.cursor = self.buffer.len();
361        self.history_idx = Some(idx);
362    }
363
364    /// Push the just-submitted entry onto the history ring (with
365    /// mode prefix) and reset transient state.
366    pub fn record_submission(&mut self, prefixed: String) {
367        if !prefixed.is_empty() {
368            self.history.push(prefixed);
369            if self.history.len() > HISTORY_CAPACITY {
370                let overflow = self.history.len() - HISTORY_CAPACITY;
371                self.history.drain(0..overflow);
372            }
373        }
374        self.buffer.clear();
375        self.cursor = 0;
376        self.mode = InputMode::Prompt;
377        self.history_idx = None;
378        self.draft.clear();
379        self.draft_mode = InputMode::Prompt;
380    }
381}
382
383pub fn strip_history_prefix(raw: &str) -> (InputMode, &str) {
384    if let Some(rest) = raw.strip_prefix('!') {
385        (InputMode::Bash, rest)
386    } else if let Some(rest) = raw.strip_prefix('#') {
387        (InputMode::Note, rest)
388    } else if let Some(rest) = raw.strip_prefix('/') {
389        (InputMode::Command, rest)
390    } else {
391        (InputMode::Prompt, raw)
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn s(buf: &str, cursor: usize) -> PromptInputState {
400        PromptInputState {
401            buffer: buf.to_string(),
402            cursor,
403            ..PromptInputState::default()
404        }
405    }
406
407    #[test]
408    fn prev_line_moves_to_same_column() {
409        let mut p = s("abc\ndef\nghi", 6); // on "def|" col 2
410        p.move_prev_line();
411        assert_eq!(p.cursor, 2, "should land on 'ab|c' of the first line");
412    }
413
414    #[test]
415    fn next_line_moves_to_same_column() {
416        let mut p = s("abc\ndef\nghi", 2); // on "ab|c" col 2
417        p.move_next_line();
418        assert_eq!(p.cursor, 6, "should land on 'de|f' of the second line");
419    }
420
421    #[test]
422    fn prev_line_handles_short_target_line() {
423        // Layout: "ab" 0..2, '\n' @ 2, "def" 3..6, '\n' @ 6, "ghi" 7..10.
424        // Cursor at 8 sits on "gh|i" (col 1 on the third line).
425        // The second line is "def" (3 chars), so col 1 stays col 1.
426        let mut p = s("ab\ndef\nghi", 8);
427        p.move_prev_line();
428        assert_eq!(p.cursor, 4, "should land on 'd|ef' of the second line");
429    }
430
431    #[test]
432    fn next_line_clamps_to_shorter_line() {
433        // Layout: "abc" 0..3, '\n' @ 3, "de" 4..6, '\n' @ 6, "ghi" 7..10.
434        // Cursor at 3 is the end of "abc|" (col 3).
435        // The second line is "de" (2 chars); col 3 clamps to 2.
436        let mut p = s("abc\nde\nghi", 3);
437        p.move_next_line();
438        assert_eq!(p.cursor, 6, "clamped to end of 'de|'");
439    }
440
441    #[test]
442    fn prev_line_noop_on_first_line() {
443        let mut p = s("hello", 3);
444        p.move_prev_line();
445        assert_eq!(p.cursor, 3, "first line is a no-op");
446    }
447
448    #[test]
449    fn next_line_noop_on_last_line() {
450        let mut p = s("hello", 3);
451        p.move_next_line();
452        assert_eq!(p.cursor, 3, "last line is a no-op");
453    }
454
455    #[test]
456    fn prev_line_three_lines_walks_back_step_by_step() {
457        // Layout: "first" 0..5, '\n' @ 5, "second" 6..12,
458        // '\n' @ 12, "third" 13..18.
459        // Cursor at 14 sits on "th|ird" (col 1 on the third line).
460        let mut p = s("first\nsecond\nthird", 14);
461        p.move_prev_line();
462        assert_eq!(p.cursor, 7, "second line, col 1 ('s|econd')");
463        p.move_prev_line();
464        assert_eq!(p.cursor, 1, "first line, col 1 ('f|irst')");
465        p.move_prev_line();
466        assert_eq!(p.cursor, 1, "first line is a no-op");
467    }
468
469    #[test]
470    fn next_line_three_lines_walks_forward_step_by_step() {
471        // Cursor at 2 sits on "fi|rst" (col 2 on the first line).
472        let mut p = s("first\nsecond\nthird", 2);
473        p.move_next_line();
474        assert_eq!(p.cursor, 8, "second line, col 2 ('seco|nd')");
475        p.move_next_line();
476        assert_eq!(p.cursor, 15, "third line, col 2 ('thi|rd')");
477        p.move_next_line();
478        assert_eq!(p.cursor, 15, "last line is a no-op");
479    }
480
481    #[test]
482    fn prev_line_handles_empty_intermediate_line() {
483        // Layout: "abc" 0..3, '\n' @ 3, "" 4..4, '\n' @ 4, "def" 5..8.
484        // Cursor at 7 sits on "de|f" (col 2 on the third line).
485        let mut p = s("abc\n\ndef", 7);
486        p.move_prev_line();
487        // Middle line is empty; col 2 clamps to 0.
488        assert_eq!(p.cursor, 4, "empty line, col 0 (just past '\\n')");
489    }
490
491    #[test]
492    fn next_line_handles_empty_intermediate_line() {
493        // Layout: "abc" 0..3, '\n' @ 3, "" 4..4, '\n' @ 4, "def" 5..8.
494        // Cursor at 1 sits on "a|bc" (col 1 on the first line).
495        let mut p = s("abc\n\ndef", 1);
496        p.move_next_line();
497        // Middle line is empty; col 1 clamps to 0.
498        assert_eq!(p.cursor, 4, "empty line, col 0");
499    }
500}