Skip to main content

opendev_repl/
repl.rs

1//! Main REPL loop: read input -> process -> display.
2//!
3//! Mirrors `opendev/repl/repl.py`.
4
5use std::io::{self, BufRead, Write};
6
7use tracing::{info, warn};
8
9use opendev_history::SessionManager;
10use opendev_runtime::AutonomyLevel;
11use opendev_tools_core::ToolRegistry;
12
13use crate::commands::{BuiltinCommands, CommandOutcome};
14use crate::error::ReplError;
15use crate::query_processor::QueryProcessor;
16
17/// Operation mode for the REPL.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum OperationMode {
20    /// Normal mode — full tool access.
21    Normal,
22    /// Plan mode — read-only tools only.
23    Plan,
24}
25
26impl std::fmt::Display for OperationMode {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            OperationMode::Normal => write!(f, "NORMAL"),
30            OperationMode::Plan => write!(f, "PLAN"),
31        }
32    }
33}
34
35/// State shared across the REPL session.
36pub struct ReplState {
37    /// Current operation mode.
38    pub mode: OperationMode,
39    /// Current autonomy level.
40    pub autonomy_level: AutonomyLevel,
41    /// Whether the REPL is running.
42    pub running: bool,
43    /// Last user prompt (for context display).
44    pub last_prompt: String,
45    /// Last operation summary.
46    pub last_operation_summary: String,
47    /// Last error message (if any).
48    pub last_error: Option<String>,
49    /// Last LLM latency in milliseconds.
50    pub last_latency_ms: Option<u64>,
51    /// Whether a plan-mode query is pending (via Shift+Tab toggle).
52    pub pending_plan_request: bool,
53    /// Flag set by /clear command; REPL loop consumes and clears session messages.
54    pub messages_cleared: bool,
55    /// Flag set by /compact command; REPL loop consumes and triggers compaction.
56    pub compact_requested: bool,
57    /// Prompt set by /init command; REPL loop consumes and processes it.
58    pub init_prompt: Option<String>,
59}
60
61impl Default for ReplState {
62    fn default() -> Self {
63        Self {
64            mode: OperationMode::Normal,
65            autonomy_level: AutonomyLevel::default(),
66            running: true,
67            last_prompt: String::new(),
68            last_operation_summary: String::from("—"),
69            last_error: None,
70            last_latency_ms: None,
71            pending_plan_request: false,
72            messages_cleared: false,
73            compact_requested: false,
74            init_prompt: None,
75        }
76    }
77}
78
79/// Interactive REPL for AI-powered coding assistance.
80///
81/// Orchestrates reading user input, dispatching slash commands,
82/// and processing AI queries via the ReAct loop.
83pub struct Repl {
84    /// Shared REPL state.
85    pub state: ReplState,
86    /// Session manager for conversation persistence.
87    session_manager: SessionManager,
88    /// Tool registry for executing tools.
89    tool_registry: ToolRegistry,
90    /// Query processor for AI interactions.
91    query_processor: QueryProcessor,
92    /// Built-in command handler.
93    commands: BuiltinCommands,
94}
95
96impl Repl {
97    /// Create a new REPL instance.
98    pub fn new(session_manager: SessionManager, tool_registry: ToolRegistry) -> Self {
99        let query_processor = QueryProcessor::new();
100        let commands = BuiltinCommands::new();
101        Self {
102            state: ReplState::default(),
103            session_manager,
104            tool_registry,
105            query_processor,
106            commands,
107        }
108    }
109
110    /// Set an initial message to be processed when the REPL starts.
111    ///
112    /// This message will be processed as a user query before entering
113    /// the interactive input loop.
114    pub fn set_initial_message(&mut self, message: String) {
115        self.state.last_prompt = message;
116    }
117
118    /// Run the REPL loop.
119    ///
120    /// Reads lines from stdin, dispatches commands or processes queries,
121    /// and loops until the user exits.
122    pub async fn run(&mut self) -> Result<(), ReplError> {
123        info!("Starting REPL");
124        self.print_welcome();
125
126        // Process initial message if one was set via set_initial_message()
127        if !self.state.last_prompt.is_empty() {
128            let initial = self.state.last_prompt.clone();
129            info!(message = %initial, "Processing initial message");
130            self.process_query(&initial).await?;
131        }
132
133        let stdin = io::stdin();
134        let mut reader = stdin.lock();
135
136        while self.state.running {
137            self.print_prompt();
138
139            let mut line = String::new();
140            match reader.read_line(&mut line) {
141                Ok(0) => {
142                    // EOF
143                    break;
144                }
145                Ok(_) => {}
146                Err(e) => {
147                    warn!(error = %e, "Error reading input");
148                    return Err(ReplError::Io(e));
149                }
150            }
151
152            let input = line.trim();
153            if input.is_empty() {
154                continue;
155            }
156
157            if input.starts_with('/') {
158                self.handle_command(input);
159
160                // Consume flags set by commands
161                if self.state.messages_cleared {
162                    self.state.messages_cleared = false;
163                    if let Some(session) = self.session_manager.current_session_mut() {
164                        session.messages.clear();
165                    }
166                }
167                if self.state.compact_requested {
168                    self.state.compact_requested = false;
169                    // Compaction will be driven by ContextCompactor when wired up.
170                    info!("Compact flag consumed; compaction will run on next query.");
171                }
172
173                if let Some(query) = self.state.init_prompt.take() {
174                    self.state.last_prompt = query.clone();
175                    self.process_query(&query).await?;
176                }
177
178                continue;
179            }
180
181            self.state.last_prompt = input.to_string();
182            self.process_query(input).await?;
183        }
184
185        self.cleanup();
186        Ok(())
187    }
188
189    /// Print the welcome banner.
190    fn print_welcome(&self) {
191        println!("OpenDev -- AI-powered coding assistant");
192        println!("Type /help for commands, /exit to quit.");
193        println!(
194            "Mode: {} | Autonomy: {}",
195            self.state.mode, self.state.autonomy_level
196        );
197        println!();
198    }
199
200    /// Print the input prompt.
201    fn print_prompt(&self) {
202        let mode_indicator = match self.state.mode {
203            OperationMode::Normal => ">",
204            OperationMode::Plan => "plan>",
205        };
206        print!("{} ", mode_indicator);
207        let _ = io::stdout().flush();
208    }
209
210    /// Handle a slash command.
211    fn handle_command(&mut self, input: &str) {
212        let parts: Vec<&str> = input.splitn(2, ' ').collect();
213        let cmd = parts[0].to_lowercase();
214        let args = parts.get(1).copied().unwrap_or("");
215
216        match self.commands.dispatch(&cmd, args, &mut self.state) {
217            CommandOutcome::Handled => {}
218            CommandOutcome::Exit => {
219                self.state.running = false;
220            }
221            CommandOutcome::Unknown => {
222                eprintln!("Unknown command: {}", cmd);
223                eprintln!("Type /help for available commands");
224            }
225        }
226    }
227
228    /// Process a user query through the AI pipeline.
229    async fn process_query(&mut self, query: &str) -> Result<(), ReplError> {
230        let plan_requested = self.state.pending_plan_request;
231        if plan_requested {
232            self.state.pending_plan_request = false;
233        }
234
235        let result = self
236            .query_processor
237            .process(
238                query,
239                &mut self.session_manager,
240                &self.tool_registry,
241                plan_requested,
242            )
243            .await?;
244
245        self.state.last_operation_summary = result.operation_summary;
246        self.state.last_error = result.error;
247        self.state.last_latency_ms = result.latency_ms;
248
249        // Print the assistant response
250        if !result.content.is_empty() {
251            println!("{}", result.content);
252        }
253
254        Ok(())
255    }
256
257    /// Clean up resources on exit.
258    fn cleanup(&mut self) {
259        info!("Cleaning up REPL resources");
260
261        // Persist mode settings into session metadata before saving
262        self.session_manager
263            .set_metadata("mode", &self.state.mode.to_string());
264        self.session_manager
265            .set_metadata("autonomy_level", &self.state.autonomy_level.to_string());
266
267        if let Err(e) = self.session_manager.save_current() {
268            warn!(error = %e, "Failed to save session on exit");
269        }
270        println!("Goodbye!");
271    }
272}
273
274#[cfg(test)]
275#[path = "repl_tests.rs"]
276mod tests;