oxios_cli/interactive.rs
1//! Interactive readline loop using reedline.
2//!
3//! Runs the main REPL: read user input, dispatch meta-commands,
4//! forward messages to the channel, and display responses.
5
6use anyhow::Result;
7use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
8
9use crate::channel::CliChannelHandle;
10use crate::commands::MetaCommand;
11
12/// The interactive read-eval-print loop.
13pub struct InteractiveLoop {
14 /// Handle to inject messages into the gateway.
15 handle: CliChannelHandle,
16 /// The reedline line editor.
17 editor: Reedline,
18 /// The prompt to display.
19 prompt: DefaultPrompt,
20}
21
22impl InteractiveLoop {
23 /// Create a new interactive loop.
24 pub fn new(handle: CliChannelHandle) -> Self {
25 let editor = Reedline::create();
26 let prompt = DefaultPrompt::default();
27
28 Self {
29 handle,
30 editor,
31 prompt,
32 }
33 }
34
35 /// Create with a custom prompt label.
36 pub fn with_prompt_label(handle: CliChannelHandle, left: &str) -> Self {
37 let editor = Reedline::create();
38 let prompt = DefaultPrompt::new(
39 DefaultPromptSegment::Basic(left.to_string()),
40 DefaultPromptSegment::Empty,
41 );
42
43 Self {
44 handle,
45 editor,
46 prompt,
47 }
48 }
49
50 /// Run the interactive loop until `.quit` or EOF.
51 ///
52 /// This is a blocking call. For use inside `tokio::task::spawn_blocking`
53 /// or a dedicated thread.
54 pub async fn run(&mut self) -> Result<()> {
55 println!("Oxios CLI — type .help for commands\n");
56
57 loop {
58 let signal = self.editor.read_line(&self.prompt);
59
60 match signal {
61 Ok(Signal::Success(line)) => {
62 let trimmed = line.trim().to_string();
63 if trimmed.is_empty() {
64 continue;
65 }
66
67 // Check for meta-commands.
68 if let Some(cmd) = MetaCommand::parse(&trimmed) {
69 if self.handle_meta(cmd)? {
70 break; // .quit
71 }
72 continue;
73 }
74
75 // Forward to the gateway.
76 self.handle.send_user_message(trimmed).await?;
77 self.handle.touch_session();
78
79 // NOTE: The response will arrive asynchronously via the
80 // Channel::send() implementation (printed to stdout).
81 // In a future iteration, we could wait for a response here
82 // for a synchronous feel, but for now the gateway routes
83 // the response back through the channel.
84 }
85 Ok(Signal::CtrlC) => {
86 println!("\n(Ctrl+C again to quit, or type .quit)");
87 }
88 Ok(Signal::CtrlD) => {
89 println!("\nGoodbye!");
90 break;
91 }
92 Err(err) => {
93 tracing::error!("Readline error: {err}");
94 break;
95 }
96 }
97 }
98
99 Ok(())
100 }
101
102 /// Handle a meta-command. Returns `true` if we should quit.
103 fn handle_meta(&self, cmd: MetaCommand) -> Result<bool> {
104 match cmd {
105 MetaCommand::Quit => {
106 println!("Goodbye!");
107 Ok(true)
108 }
109 MetaCommand::Help => {
110 print!("{}", MetaCommand::help_text());
111 Ok(false)
112 }
113 MetaCommand::Reset => {
114 self.handle.reset_session();
115 println!("Session reset.");
116 Ok(false)
117 }
118 MetaCommand::Model(Some(name)) => {
119 println!("Switching model to: {name}");
120 // TODO: wire to kernel model switching
121 Ok(false)
122 }
123 MetaCommand::Model(None) => {
124 println!("Current model: (default)");
125 Ok(false)
126 }
127 MetaCommand::Persona(Some(name)) => {
128 println!("Switching persona to: {name}");
129 // TODO: wire to kernel persona switching
130 Ok(false)
131 }
132 MetaCommand::Persona(None) => {
133 println!("Current persona: (default)");
134 Ok(false)
135 }
136 MetaCommand::Clear => {
137 // ANSI clear screen.
138 print!("\x1b[2J\x1b[H");
139 Ok(false)
140 }
141 }
142 }
143}