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}