Skip to main content

a_agent/tui/
input.rs

1use std::borrow::Cow;
2use std::io;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex};
5
6use dialoguer::Select;
7use rustyline::completion::{Completer, Pair};
8use rustyline::error::ReadlineError;
9use rustyline::highlight::Highlighter;
10use rustyline::hint::{Hint, Hinter};
11use rustyline::history::DefaultHistory;
12use rustyline::validate::Validator;
13use rustyline::{
14    Cmd, ConditionalEventHandler, Context, Editor, Event, EventContext, EventHandler, Helper,
15    KeyCode, KeyEvent, Modifiers, RepeatCount,
16};
17use unicode_width::UnicodeWidthStr;
18
19use super::InlineRenderer;
20
21const SLASH_COMMANDS: &[SlashCommand] = &[
22    SlashCommand::new("/model", "/model [profile]", "Switch model profile"),
23    SlashCommand::new("/effort", "/effort [level]", "Set reasoning effort"),
24    SlashCommand::new("/thinking", "/thinking", "Toggle reasoning visibility"),
25    SlashCommand::new("/status", "/status", "Show session and model status"),
26    SlashCommand::new("/clear", "/clear", "Clear the current conversation"),
27    SlashCommand::new("/compact", "/compact", "Summarize the current conversation"),
28    SlashCommand::new(
29        "/resume",
30        "/resume [session-id]",
31        "Resume a session in this cwd",
32    ),
33    SlashCommand::new("/help", "/help", "Show available commands"),
34];
35
36struct SlashCommand {
37    name: &'static str,
38    usage: &'static str,
39    description: &'static str,
40}
41
42impl SlashCommand {
43    const fn new(name: &'static str, usage: &'static str, description: &'static str) -> Self {
44        Self {
45            name,
46            usage,
47            description,
48        }
49    }
50}
51
52#[derive(Default)]
53struct PaletteState(Mutex<PaletteSelection>);
54
55#[derive(Default)]
56struct PaletteSelection {
57    prefix: String,
58    index: usize,
59}
60
61impl PaletteState {
62    fn selected(&self, prefix: &str, count: usize) -> usize {
63        let Ok(mut state) = self.0.lock() else {
64            return 0;
65        };
66        if state.prefix != prefix {
67            state.prefix = prefix.into();
68            state.index = 0;
69        }
70        state.index = state.index.min(count.saturating_sub(1));
71        state.index
72    }
73
74    fn move_selection(&self, prefix: &str, count: usize, direction: isize) {
75        if count == 0 {
76            return;
77        }
78        let Ok(mut state) = self.0.lock() else {
79            return;
80        };
81        if state.prefix != prefix {
82            state.prefix = prefix.into();
83            state.index = 0;
84        }
85        state.index = (state.index as isize + direction).rem_euclid(count as isize) as usize;
86    }
87
88    fn clear(&self) {
89        if let Ok(mut state) = self.0.lock() {
90            state.prefix.clear();
91            state.index = 0;
92        }
93    }
94}
95
96struct AgentHint(String);
97
98impl Hint for AgentHint {
99    fn display(&self) -> &str {
100        &self.0
101    }
102
103    fn completion(&self) -> Option<&str> {
104        None
105    }
106}
107
108fn command_prefix(line: &str, position: usize) -> Option<&str> {
109    if position != line.len() {
110        return None;
111    }
112    let prefix = &line[..position];
113    (prefix.starts_with('/') && !prefix.chars().any(char::is_whitespace)).then_some(prefix)
114}
115
116fn matching_commands(prefix: &str) -> Vec<&'static SlashCommand> {
117    SLASH_COMMANDS
118        .iter()
119        .filter(|command| command.name.starts_with(prefix))
120        .collect()
121}
122
123fn command_row(command: &SlashCommand, selected: bool, color: bool) -> String {
124    let marker = if selected { '›' } else { ' ' };
125    let row = format!("{marker} {:<22} {}", command.usage, command.description);
126    if !color {
127        return row;
128    }
129    if selected {
130        format!("\x1b[1;96m{row}\x1b[0m")
131    } else {
132        format!("\x1b[90m{row}\x1b[0m")
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum InputAction {
138    Submit(String, InputMode),
139    Rewind,
140    ToggleReasoning,
141    Interrupt,
142    Eof,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum InputMode {
147    Once,
148    Multi,
149}
150
151#[derive(Clone)]
152struct AgentHelper {
153    multi: Arc<AtomicBool>,
154    palette: Arc<PaletteState>,
155}
156
157impl Completer for AgentHelper {
158    type Candidate = Pair;
159
160    fn complete(
161        &self,
162        line: &str,
163        position: usize,
164        _context: &Context<'_>,
165    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
166        let Some(prefix) = command_prefix(line, position) else {
167            return Ok((0, Vec::new()));
168        };
169        let commands = matching_commands(prefix);
170        let selected = self.palette.selected(prefix, commands.len());
171        let candidates = commands
172            .get(selected)
173            .map(|command| Pair {
174                display: command.usage.into(),
175                replacement: format!("{} ", command.name),
176            })
177            .into_iter()
178            .collect();
179        Ok((0, candidates))
180    }
181}
182
183impl Hinter for AgentHelper {
184    type Hint = AgentHint;
185
186    fn hint(&self, line: &str, position: usize, _context: &Context<'_>) -> Option<AgentHint> {
187        if position != line.len() {
188            self.palette.clear();
189            return None;
190        }
191        if let Some(prefix) = command_prefix(line, position) {
192            let commands = matching_commands(prefix);
193            let selected = self.palette.selected(prefix, commands.len());
194            let color = std::env::var_os("NO_COLOR").is_none();
195            let rows = commands
196                .iter()
197                .enumerate()
198                .map(|(index, command)| command_row(command, index == selected, color))
199                .collect::<Vec<_>>();
200            return Some(AgentHint(if rows.is_empty() {
201                "\n  No matching commands".into()
202            } else {
203                format!("\n{}", rows.join("\n"))
204            }));
205        }
206        self.palette.clear();
207        let label = if self.multi.load(Ordering::SeqCst) {
208            "multi · tab"
209        } else {
210            "once · tab"
211        };
212        let terminal_width = crossterm::terminal::size()
213            .map(|(width, _)| usize::from(width))
214            .unwrap_or(80);
215        let used = 3 + UnicodeWidthStr::width(line) + UnicodeWidthStr::width(label);
216        (terminal_width > used + 1)
217            .then(|| AgentHint(format!("{}{label}", " ".repeat(terminal_width - used - 1))))
218    }
219}
220
221impl Highlighter for AgentHelper {
222    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
223        if hint.starts_with('\n') {
224            return Cow::Borrowed(hint);
225        }
226        if self.multi.load(Ordering::SeqCst) {
227            Cow::Owned(format!("\x1b[1;95m{hint}\x1b[0m"))
228        } else {
229            Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
230        }
231    }
232}
233
234impl Validator for AgentHelper {}
235impl Helper for AgentHelper {}
236
237struct RewindHandler(Arc<AtomicBool>);
238
239impl ConditionalEventHandler for RewindHandler {
240    fn handle(
241        &self,
242        _event: &Event,
243        _repeat: RepeatCount,
244        _positive: bool,
245        _context: &EventContext,
246    ) -> Option<Cmd> {
247        self.0.store(true, Ordering::SeqCst);
248        Some(Cmd::Interrupt)
249    }
250}
251
252struct ReasoningHandler(Arc<Mutex<Option<(String, String)>>>);
253
254impl ConditionalEventHandler for ReasoningHandler {
255    fn handle(
256        &self,
257        _event: &Event,
258        _repeat: RepeatCount,
259        _positive: bool,
260        context: &EventContext,
261    ) -> Option<Cmd> {
262        let position = context.pos();
263        let line = context.line();
264        if let Ok(mut pending) = self.0.lock() {
265            *pending = Some((line[..position].to_owned(), line[position..].to_owned()));
266        }
267        Some(Cmd::Interrupt)
268    }
269}
270
271struct TabHandler {
272    multi: Arc<AtomicBool>,
273    palette: Arc<PaletteState>,
274}
275
276impl ConditionalEventHandler for TabHandler {
277    fn handle(
278        &self,
279        _event: &Event,
280        _repeat: RepeatCount,
281        _positive: bool,
282        context: &EventContext,
283    ) -> Option<Cmd> {
284        if let Some(prefix) = command_prefix(context.line(), context.pos()) {
285            let commands = matching_commands(prefix);
286            self.palette.selected(prefix, commands.len());
287            return Some(Cmd::Complete);
288        }
289        self.multi.fetch_xor(true, Ordering::SeqCst);
290        Some(Cmd::Repaint)
291    }
292}
293
294struct PaletteNavigation {
295    palette: Arc<PaletteState>,
296    direction: isize,
297}
298
299impl ConditionalEventHandler for PaletteNavigation {
300    fn handle(
301        &self,
302        _event: &Event,
303        _repeat: RepeatCount,
304        _positive: bool,
305        context: &EventContext,
306    ) -> Option<Cmd> {
307        let prefix = command_prefix(context.line(), context.pos())?;
308        let count = matching_commands(prefix).len();
309        self.palette.move_selection(prefix, count, self.direction);
310        Some(Cmd::Repaint)
311    }
312}
313
314pub struct InputEditor {
315    editor: Editor<AgentHelper, DefaultHistory>,
316    rewind_requested: Arc<AtomicBool>,
317    reasoning_requested: Arc<Mutex<Option<(String, String)>>>,
318    pending_initial: Option<(String, String)>,
319    reasoning_key: char,
320    multi: Arc<AtomicBool>,
321    palette: Arc<PaletteState>,
322}
323
324impl InputEditor {
325    pub fn with_reasoning_toggle(value: &str) -> io::Result<Self> {
326        let reasoning_key = value
327            .strip_prefix("ctrl-")
328            .and_then(|value| {
329                let mut characters = value.chars();
330                let key = characters.next()?;
331                characters.next().is_none().then_some(key)
332            })
333            .ok_or_else(|| {
334                io::Error::new(
335                    io::ErrorKind::InvalidInput,
336                    "reasoning toggle must use ctrl-<character>",
337                )
338            })?;
339        let rewind_requested = Arc::new(AtomicBool::new(false));
340        let reasoning_requested = Arc::new(Mutex::new(None));
341        let multi = Arc::new(AtomicBool::new(true));
342        let palette = Arc::new(PaletteState::default());
343        let editor_config = rustyline::Config::builder()
344            .keyseq_timeout(Some(500))
345            .build();
346        let mut editor = Editor::<AgentHelper, DefaultHistory>::with_config(editor_config)
347            .map_err(io::Error::other)?;
348        editor.set_helper(Some(AgentHelper {
349            multi: multi.clone(),
350            palette: palette.clone(),
351        }));
352        editor.bind_sequence(
353            Event::KeySeq(vec![KeyEvent::from('\x1b'), KeyEvent::from('\x1b')]),
354            EventHandler::Conditional(Box::new(RewindHandler(rewind_requested.clone()))),
355        );
356        editor.bind_sequence(
357            KeyEvent(KeyCode::Esc, Modifiers::ALT),
358            EventHandler::Conditional(Box::new(RewindHandler(rewind_requested.clone()))),
359        );
360        editor.bind_sequence(
361            KeyEvent(KeyCode::Esc, Modifiers::NONE),
362            EventHandler::Conditional(Box::new(RewindHandler(rewind_requested.clone()))),
363        );
364        editor.bind_sequence(
365            KeyEvent::ctrl(reasoning_key),
366            EventHandler::Conditional(Box::new(ReasoningHandler(reasoning_requested.clone()))),
367        );
368        editor.bind_sequence(
369            KeyEvent(KeyCode::Tab, Modifiers::NONE),
370            EventHandler::Conditional(Box::new(TabHandler {
371                multi: multi.clone(),
372                palette: palette.clone(),
373            })),
374        );
375        editor.bind_sequence(
376            KeyEvent(KeyCode::Up, Modifiers::NONE),
377            EventHandler::Conditional(Box::new(PaletteNavigation {
378                palette: palette.clone(),
379                direction: -1,
380            })),
381        );
382        editor.bind_sequence(
383            KeyEvent(KeyCode::Down, Modifiers::NONE),
384            EventHandler::Conditional(Box::new(PaletteNavigation {
385                palette: palette.clone(),
386                direction: 1,
387            })),
388        );
389        Ok(Self {
390            editor,
391            rewind_requested,
392            reasoning_requested,
393            pending_initial: None,
394            reasoning_key,
395            multi,
396            palette,
397        })
398    }
399
400    pub fn reasoning_key(&self) -> char {
401        self.reasoning_key
402    }
403
404    pub fn is_reasoning_toggle(
405        &self,
406        code: crossterm::event::KeyCode,
407        modifiers: crossterm::event::KeyModifiers,
408    ) -> bool {
409        matches!(code, crossterm::event::KeyCode::Char(character) if character == self.reasoning_key)
410            && modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
411    }
412
413    pub fn read_action(&mut self) -> io::Result<InputAction> {
414        let prompt = ("a> ", "\x1b[1;96ma> \x1b[0m");
415        let result = if let Some((left, right)) = self.pending_initial.take() {
416            self.editor.readline_with_initial(&prompt, (&left, &right))
417        } else {
418            self.editor.readline(&prompt)
419        };
420        match result {
421            Ok(line) => {
422                let line = self.accept_palette_selection(line);
423                if !line.trim().is_empty() {
424                    self.editor
425                        .add_history_entry(line.as_str())
426                        .map_err(io::Error::other)?;
427                }
428                let mode = if self.multi.load(Ordering::SeqCst) {
429                    InputMode::Multi
430                } else {
431                    InputMode::Once
432                };
433                Ok(InputAction::Submit(line, mode))
434            }
435            Err(ReadlineError::Interrupted)
436                if self.rewind_requested.swap(false, Ordering::SeqCst) =>
437            {
438                Ok(InputAction::Rewind)
439            }
440            Err(ReadlineError::Interrupted) if self.take_reasoning_request() => {
441                Ok(InputAction::ToggleReasoning)
442            }
443            Err(ReadlineError::Interrupted) => Ok(InputAction::Interrupt),
444            Err(ReadlineError::Eof) => Ok(InputAction::Eof),
445            Err(error) => Err(io::Error::other(error)),
446        }
447    }
448
449    fn accept_palette_selection(&self, line: String) -> String {
450        let Some(prefix) = command_prefix(&line, line.len()) else {
451            return line;
452        };
453        let commands = matching_commands(prefix);
454        let selected = self.palette.selected(prefix, commands.len());
455        commands
456            .get(selected)
457            .map_or(line, |command| command.name.into())
458    }
459
460    pub fn add_history_entries(&mut self, entries: &[String]) -> io::Result<()> {
461        for entry in entries {
462            self.editor
463                .add_history_entry(entry.as_str())
464                .map_err(io::Error::other)?;
465        }
466        Ok(())
467    }
468
469    pub fn select_option(
470        &mut self,
471        prompt: &str,
472        choices: &[String],
473        default: usize,
474    ) -> io::Result<Option<usize>> {
475        if choices.is_empty() {
476            return Ok(None);
477        }
478        Select::new()
479            .with_prompt(prompt)
480            .items(choices)
481            .default(default.min(choices.len() - 1))
482            .interact_opt()
483            .map_err(io::Error::other)
484    }
485
486    fn take_reasoning_request(&mut self) -> bool {
487        let Ok(mut requested) = self.reasoning_requested.lock() else {
488            return false;
489        };
490        let Some(initial) = requested.take() else {
491            return false;
492        };
493        self.pending_initial = Some(initial);
494        true
495    }
496
497    pub fn select_checkpoint(
498        &mut self,
499        checkpoints: &[(String, String)],
500        renderer: &InlineRenderer,
501    ) -> io::Result<Option<String>> {
502        if checkpoints.is_empty() {
503            renderer.render_status("no user messages to rewind to")?;
504            return Ok(None);
505        }
506        let labels = checkpoints
507            .iter()
508            .map(|(_, label)| label.clone())
509            .collect::<Vec<_>>();
510        let Some(index) = self.select_option("Rewind to", &labels, 0)? else {
511            return Ok(None);
512        };
513        Ok(Some(checkpoints[index].0.clone()))
514    }
515}