Skip to main content

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//!
6//! Implements the "sequential input" model from RFC-014 Phase 2:
7//! while a request is being processed, new input is rejected with a
8//! message instead of being queued. This prevents the fire-and-forget
9//! confusion where responses arrive mid-typing.
10
11use anyhow::Result;
12use reedline::{DefaultPrompt, DefaultPromptSegment, Reedline, Signal};
13
14use crate::channel::CliChannelHandle;
15use crate::commands::MetaCommand;
16
17/// The interactive read-eval-print loop.
18pub struct InteractiveLoop {
19    /// Handle to inject messages into the gateway.
20    handle: CliChannelHandle,
21    /// The reedline line editor.
22    editor: Reedline,
23    /// The prompt to display.
24    prompt: DefaultPrompt,
25}
26
27impl InteractiveLoop {
28    /// Create a new interactive loop.
29    pub fn new(handle: CliChannelHandle) -> Self {
30        let editor = Reedline::create();
31        let prompt = DefaultPrompt::default();
32
33        Self {
34            handle,
35            editor,
36            prompt,
37        }
38    }
39
40    /// Create with a custom prompt label.
41    pub fn with_prompt_label(handle: CliChannelHandle, left: &str) -> Self {
42        let editor = Reedline::create();
43        let prompt = DefaultPrompt::new(
44            DefaultPromptSegment::Basic(left.to_string()),
45            DefaultPromptSegment::Empty,
46        );
47
48        Self {
49            handle,
50            editor,
51            prompt,
52        }
53    }
54
55    /// Run the interactive loop until `.quit` or EOF.
56    ///
57    /// This is a blocking call. For use inside `tokio::task::spawn_blocking`
58    /// or a dedicated thread.
59    pub async fn run(&mut self) -> Result<()> {
60        println!("Oxios CLI — type .help for commands\n");
61
62        loop {
63            let signal = self.editor.read_line(&self.prompt);
64
65            match signal {
66                Ok(Signal::Success(line)) => {
67                    let trimmed = line.trim().to_string();
68                    if trimmed.is_empty() {
69                        continue;
70                    }
71
72                    // Check for meta-commands.
73                    if let Some(cmd) = MetaCommand::parse(&trimmed) {
74                        if self.handle_meta(cmd).await? {
75                            break; // .quit
76                        }
77                        continue;
78                    }
79
80                    // Reject input while a previous request is still in-flight.
81                    if self.handle.is_processing() {
82                        println!("⏳ 이전 요청을 처리 중입니다. 잠시만 기다려주세요.");
83                        continue;
84                    }
85
86                    // Mark as processing, then forward to the gateway.
87                    self.handle.set_processing(true);
88                    self.handle.send_user_message(trimmed).await?;
89                    self.handle.touch_session();
90
91                    // NOTE: The response will arrive asynchronously via the
92                    // Channel::send() implementation (printed to stdout).
93                    // In a future iteration, we could wait for a response here
94                    // for a synchronous feel, but for now the gateway routes
95                    // the response back through the channel.
96                }
97                Ok(Signal::CtrlC) => {
98                    println!("\n(Ctrl+C again to quit, or type .quit)");
99                }
100                Ok(Signal::CtrlD) => {
101                    println!("\nGoodbye!");
102                    break;
103                }
104                Err(err) => {
105                    tracing::error!("Readline error: {err}");
106                    break;
107                }
108            }
109        }
110
111        Ok(())
112    }
113
114    /// Handle a meta-command. Returns `true` if we should quit.
115    async fn handle_meta(&self, cmd: MetaCommand) -> Result<bool> {
116        match cmd {
117            MetaCommand::Quit => {
118                println!("Goodbye!");
119                Ok(true)
120            }
121            MetaCommand::Help => {
122                print!("{}", MetaCommand::help_text());
123                Ok(false)
124            }
125            MetaCommand::Reset => {
126                self.handle.reset_session();
127                println!("Session reset.");
128                Ok(false)
129            }
130            MetaCommand::Model(Some(name)) => {
131                println!("Switching model to: {name}");
132                self.handle.send_switch_model(&name).await?;
133                Ok(false)
134            }
135            MetaCommand::Model(None) => {
136                println!("Current model: (default)");
137                Ok(false)
138            }
139            MetaCommand::Persona(Some(name)) => {
140                println!("Switching persona to: {name}");
141                self.handle.send_switch_persona(&name).await?;
142                Ok(false)
143            }
144            MetaCommand::Persona(None) => {
145                println!("Current persona: (default)");
146                Ok(false)
147            }
148            MetaCommand::Space(None) => {
149                // Space info is managed by the kernel via message routing.
150                // Channels don't have direct kernel access.
151                println!("📋 .space 명령어는 현재 Surface(Web 대시보드)에서만 사용 가능합니다.");
152                Ok(false)
153            }
154            MetaCommand::Space(Some(_id_or_name)) => {
155                // Space switching requires kernel access.
156                // Channels don't have direct kernel access.
157                println!("📋 .space 명령어는 현재 Surface(Web 대시보드)에서만 사용 가능합니다.");
158                Ok(false)
159            }
160            MetaCommand::Spaces => {
161                println!("📋 .spaces 명령어는 현재 Surface(Web 대시보드)에서만 사용 가능합니다.");
162                Ok(false)
163            }
164            MetaCommand::Clear => {
165                // ANSI clear screen.
166                print!("\x1b[2J\x1b[H");
167                Ok(false)
168            }
169        }
170    }
171}