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}