Skip to main content

navi_core/
plan_mode.rs

1//! Plan Mode — a collaboration phase where the agent proposes a plan before execution.
2//!
3//! Inspired by Codex's Plan Mode, this separates the "think and plan" phase
4//! from the "execute" phase. In Plan mode:
5//! - Only read-only tools are available (read_file, grep, fs_browser, ast_search, etc.)
6//! - The model emits `<proposed_plan>` tags in its text stream
7//! - The parser extracts the plan in real-time
8//! - When the turn completes, a `PlanProposed` event is emitted
9//! - The UI shows a confirmation popup: "Implement this plan?"
10//! - If confirmed, a Goal with checklist is created and the mode switches to Default
11
12use crate::tool::ToolKind;
13use serde::{Deserialize, Serialize};
14
15/// The collaboration mode of the agent.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AgentMode {
19    /// Normal execution mode — all tools available, full agentic loop.
20    Default,
21    /// Plan mode — only read-only tools, model proposes a plan via text tags.
22    Plan,
23}
24
25impl AgentMode {
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            Self::Default => "default",
29            Self::Plan => "plan",
30        }
31    }
32
33    /// Returns true if this mode restricts tool access.
34    pub fn restricts_tools(&self) -> bool {
35        matches!(self, Self::Plan)
36    }
37}
38
39impl Default for AgentMode {
40    fn default() -> Self {
41        Self::Default
42    }
43}
44
45impl std::fmt::Display for AgentMode {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.as_str())
48    }
49}
50
51/// A proposed plan extracted from the model's text stream.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ProposedPlan {
54    /// Title/summary of the plan.
55    pub title: String,
56    /// Ordered list of steps to execute.
57    pub steps: Vec<String>,
58}
59
60impl ProposedPlan {
61    pub fn new(title: String, steps: Vec<String>) -> Self {
62        Self { title, steps }
63    }
64
65    pub fn is_empty(&self) -> bool {
66        self.steps.is_empty() && self.title.is_empty()
67    }
68}
69
70/// Parses `<proposed_plan>` blocks from streaming text in real-time.
71///
72/// The model emits plans like:
73/// ```text
74/// <proposed_plan title="Fix the bug">
75/// 1. Read the file
76/// 2. Fix the function
77/// 3. Run tests
78/// </proposed_plan>
79/// ```
80///
81/// The parser accumulates text chunks and extracts the plan when the
82/// closing tag is found.
83#[derive(Debug, Default)]
84pub struct ProposedPlanParser {
85    buffer: String,
86    in_plan: bool,
87    plan_title: Option<String>,
88    plan_body: String,
89    completed_plans: Vec<ProposedPlan>,
90}
91
92impl ProposedPlanParser {
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Feed a text delta into the parser.
98    /// Returns any plans that were completed by this chunk.
99    pub fn push_text(&mut self, text: &str) -> Vec<ProposedPlan> {
100        self.buffer.push_str(text);
101        self.drain_completed()
102    }
103
104    /// Drain any pending plans (call at end of turn).
105    pub fn drain(&mut self) -> Vec<ProposedPlan> {
106        // Move any remaining buffer content into plan_body if inside a plan.
107        if self.in_plan {
108            self.plan_body.push_str(&self.buffer);
109            self.buffer.clear();
110            if !self.plan_body.is_empty() {
111                let plan = self.finalize_plan();
112                self.completed_plans.push(plan);
113            }
114        }
115        std::mem::take(&mut self.completed_plans)
116    }
117
118    /// Returns true if the parser is currently inside a `<proposed_plan>` block.
119    pub fn is_in_plan(&self) -> bool {
120        self.in_plan
121    }
122
123    /// Returns the partial plan body being accumulated (for live UI preview).
124    pub fn partial_body(&self) -> &str {
125        if self.in_plan { &self.plan_body } else { "" }
126    }
127
128    /// Returns the parsed title of the current plan (for live UI preview).
129    pub fn partial_title(&self) -> Option<&str> {
130        if self.in_plan {
131            self.plan_title.as_deref()
132        } else {
133            None
134        }
135    }
136
137    fn drain_completed(&mut self) -> Vec<ProposedPlan> {
138        loop {
139            if self.in_plan {
140                // Look for closing tag in buffer
141                if let Some(pos) = self.buffer.find("</proposed_plan>") {
142                    self.plan_body.push_str(&self.buffer[..pos]);
143                    self.buffer = self.buffer[pos + "</proposed_plan>".len()..].to_string();
144                    self.in_plan = false;
145                    let plan = self.finalize_plan();
146                    self.completed_plans.push(plan);
147                } else {
148                    // No closing tag yet. Move all buffer content into plan_body
149                    // except a suffix that could be the start of "</proposed_plan>".
150                    let tag = "</proposed_plan>";
151                    if self.buffer.len() >= tag.len() {
152                        let safe = self.buffer.len() - tag.len() + 1;
153                        self.plan_body.push_str(&self.buffer[..safe]);
154                        self.buffer = self.buffer[safe..].to_string();
155                    } else {
156                        // Buffer is shorter than the tag — move everything.
157                        self.plan_body.push_str(&self.buffer);
158                        self.buffer.clear();
159                    }
160                    break;
161                }
162            } else {
163                // Look for opening tag
164                if let Some(tag_info) = self.find_opening_tag() {
165                    self.buffer = self.buffer[tag_info.consume_len..].to_string();
166                    self.in_plan = true;
167                    self.plan_title = tag_info.title;
168                    self.plan_body.clear();
169                } else {
170                    // No opening tag found. Keep only a suffix that could be
171                    // the start of "<proposed_plan".
172                    let tag = "<proposed_plan";
173                    let safe = self.buffer.len().saturating_sub(tag.len() - 1);
174                    self.buffer = self.buffer[safe..].to_string();
175                    break;
176                }
177            }
178        }
179        std::mem::take(&mut self.completed_plans)
180    }
181
182    fn finalize_plan(&mut self) -> ProposedPlan {
183        let title = self.plan_title.take().unwrap_or_default();
184        let body = std::mem::take(&mut self.plan_body);
185        let steps = parse_plan_steps(&body);
186        ProposedPlan::new(title, steps)
187    }
188
189    fn find_opening_tag(&self) -> Option<OpeningTagInfo> {
190        let start = self.buffer.find("<proposed_plan")?;
191        let rest = &self.buffer[start..];
192        let tag_end = rest.find('>')?;
193        let tag_content = &rest[..tag_end];
194        let consume_len = start + tag_end + 1;
195
196        let title = tag_content
197            .find("title=\"")
198            .and_then(|t_pos| {
199                let value_start = t_pos + "title=\"".len();
200                tag_content[value_start..]
201                    .find('"')
202                    .map(|end| tag_content[value_start..value_start + end].to_string())
203            })
204            .or_else(|| {
205                tag_content.find("title='").and_then(|t_pos| {
206                    let value_start = t_pos + "title='".len();
207                    tag_content[value_start..]
208                        .find('\'')
209                        .map(|end| tag_content[value_start..value_start + end].to_string())
210                })
211            });
212
213        Some(OpeningTagInfo { consume_len, title })
214    }
215}
216
217struct OpeningTagInfo {
218    consume_len: usize,
219    title: Option<String>,
220}
221
222/// Parses plan body text into steps.
223/// Supports numbered lists, bullet lists, and plain lines.
224fn parse_plan_steps(body: &str) -> Vec<String> {
225    body.lines()
226        .map(|line| line.trim())
227        .filter(|line| !line.is_empty())
228        .map(|line| {
229            // Strip leading markers: "1. ", "1) ", "- ", "* ", "• "
230            let stripped = if let Some(rest) = line.strip_prefix("- ") {
231                rest.to_string()
232            } else if let Some(rest) = line.strip_prefix("* ") {
233                rest.to_string()
234            } else if let Some(rest) = line.strip_prefix("• ") {
235                rest.to_string()
236            } else {
237                // Strip numbered prefix: "1. ", "1) ", "10. ", etc.
238                let chars: Vec<char> = line.chars().collect();
239                let mut idx = 0;
240                while idx < chars.len() && chars[idx].is_ascii_digit() {
241                    idx += 1;
242                }
243                if idx > 0 && idx < chars.len() && (chars[idx] == '.' || chars[idx] == ')') {
244                    idx += 1;
245                    while idx < chars.len() && chars[idx] == ' ' {
246                        idx += 1;
247                    }
248                    line[idx..].to_string()
249                } else {
250                    line.to_string()
251                }
252            };
253            if stripped.is_empty() {
254                line.to_string()
255            } else {
256                stripped
257            }
258        })
259        .collect()
260}
261
262/// Returns true if a tool kind is allowed in Plan mode.
263/// Only read-only and search tools are permitted.
264pub fn is_tool_allowed_in_plan_mode(kind: ToolKind) -> bool {
265    matches!(kind, ToolKind::Read)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn parser_extracts_simple_plan() {
274        let mut parser = ProposedPlanParser::new();
275        let plans = parser.push_text(
276            "Let me analyze this.\n\
277             <proposed_plan title=\"Fix the bug\">\n\
278             1. Read the file\n\
279             2. Fix the function\n\
280             3. Run tests\n\
281             </proposed_plan>\n\
282             Done.",
283        );
284        assert_eq!(plans.len(), 1);
285        assert_eq!(plans[0].title, "Fix the bug");
286        assert_eq!(plans[0].steps.len(), 3);
287        assert_eq!(plans[0].steps[0], "Read the file");
288        assert_eq!(plans[0].steps[1], "Fix the function");
289        assert_eq!(plans[0].steps[2], "Run tests");
290    }
291
292    #[test]
293    fn parser_extracts_plan_without_title() {
294        let mut parser = ProposedPlanParser::new();
295        let plans = parser.push_text(
296            "<proposed_plan>\n\
297             - Step one\n\
298             - Step two\n\
299             </proposed_plan>",
300        );
301        assert_eq!(plans.len(), 1);
302        assert_eq!(plans[0].title, "");
303        assert_eq!(plans[0].steps, vec!["Step one", "Step two"]);
304    }
305
306    #[test]
307    fn parser_handles_chunked_stream() {
308        let mut parser = ProposedPlanParser::new();
309        let chunks = [
310            "Let me think.\n<propos",
311            "ed_plan title=\"My Plan\">\n",
312            "1. First step\n2. Second ",
313            "step\n</proposed_",
314            "plan>\nDone.",
315        ];
316
317        let mut all_plans = Vec::new();
318        for chunk in &chunks {
319            all_plans.extend(parser.push_text(chunk));
320        }
321
322        assert_eq!(all_plans.len(), 1);
323        assert_eq!(all_plans[0].title, "My Plan");
324        assert_eq!(all_plans[0].steps, vec!["First step", "Second step"]);
325    }
326
327    #[test]
328    fn parser_handles_multiple_plans() {
329        let mut parser = ProposedPlanParser::new();
330        let plans = parser.push_text(
331            "<proposed_plan title=\"Plan A\">\n1. A1\n</proposed_plan>\n\
332             <proposed_plan title=\"Plan B\">\n1. B1\n</proposed_plan>",
333        );
334        assert_eq!(plans.len(), 2);
335        assert_eq!(plans[0].title, "Plan A");
336        assert_eq!(plans[1].title, "Plan B");
337    }
338
339    #[test]
340    fn parser_drain_unclosed_plan() {
341        let mut parser = ProposedPlanParser::new();
342        parser.push_text("<proposed_plan title=\"Unclosed\">\n1. Step\n");
343        let plans = parser.drain();
344        assert_eq!(plans.len(), 1);
345        assert_eq!(plans[0].title, "Unclosed");
346        assert_eq!(plans[0].steps, vec!["Step"]);
347    }
348
349    #[test]
350    fn parser_partial_preview() {
351        let mut parser = ProposedPlanParser::new();
352        parser.push_text("<proposed_plan title=\"Live\">\n1. First");
353        assert!(parser.is_in_plan());
354        assert_eq!(parser.partial_title(), Some("Live"));
355        assert!(parser.partial_body().contains("First"));
356    }
357
358    #[test]
359    fn parser_no_plan_in_regular_text() {
360        let mut parser = ProposedPlanParser::new();
361        let plans = parser.push_text("Just regular text without any plan tags.");
362        assert!(plans.is_empty());
363        assert!(!parser.is_in_plan());
364    }
365
366    #[test]
367    fn parse_plan_steps_strips_numbering() {
368        let steps = parse_plan_steps("1. First\n2. Second\n3. Third");
369        assert_eq!(steps, vec!["First", "Second", "Third"]);
370    }
371
372    #[test]
373    fn parse_plan_steps_strips_bullets() {
374        let steps = parse_plan_steps("- Alpha\n* Beta");
375        assert_eq!(steps, vec!["Alpha", "Beta"]);
376    }
377
378    #[test]
379    fn agent_mode_default_is_default() {
380        assert_eq!(AgentMode::default(), AgentMode::Default);
381    }
382
383    #[test]
384    fn agent_mode_plan_restricts_tools() {
385        assert!(AgentMode::Plan.restricts_tools());
386        assert!(!AgentMode::Default.restricts_tools());
387    }
388
389    #[test]
390    fn only_read_tools_allowed_in_plan_mode() {
391        assert!(is_tool_allowed_in_plan_mode(ToolKind::Read));
392        assert!(!is_tool_allowed_in_plan_mode(ToolKind::Write));
393        assert!(!is_tool_allowed_in_plan_mode(ToolKind::Command));
394        assert!(!is_tool_allowed_in_plan_mode(ToolKind::Custom));
395    }
396}