1use crate::agent::thread_identity::current_agent_name;
2use crate::message_types::PlanDecision;
3use crate::message_types::{AskOption, AskQuestion, AskRequest};
4use crate::permission::JcliConfig;
5use crate::tools::{Tool, ToolResult, schema_to_tool_params};
6use schemars::JsonSchema;
7use serde::Deserialize;
8use serde_json::Value;
9use std::borrow::Cow;
10use std::sync::{Arc, atomic::AtomicBool, mpsc};
11
12pub use crate::context::plan_state::{
14 PendingPlanApproval, PlanApprovalQueue, PlanModeState, is_allowed_in_plan_mode,
15};
16
17fn sanitize_filename(s: &str) -> String {
21 s.chars()
22 .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c > '\u{4e00}')
23 .collect::<String>()
24 .trim()
25 .to_string()
26}
27
28#[derive(Deserialize, JsonSchema)]
30struct EnterPlanModeParams {
31 #[serde(default)]
33 description: Option<String>,
34}
35
36#[derive(Debug)]
38pub struct EnterPlanModeTool {
39 pub plan_state: Arc<PlanModeState>,
41}
42
43impl EnterPlanModeTool {
44 pub const NAME: &'static str = "EnterPlanMode";
45}
46
47impl Tool for EnterPlanModeTool {
48 fn name(&self) -> &str {
49 Self::NAME
50 }
51
52 fn description(&self) -> Cow<'_, str> {
53 r#"
54 Enter plan mode to explore the codebase and design an implementation approach before writing code.
55 In plan mode, only read-only tools (Read, Glob, Grep, WebFetch, WebSearch, Ask, etc.) are available.
56 Write tools (Shell, Write, Edit, etc.) will be blocked until plan mode is exited.
57
58 Use this proactively before starting non-trivial implementation tasks. Prefer using EnterPlanMode when ANY of these apply:
59 - New feature implementation with architectural decisions
60 - Multiple valid approaches exist and user should choose
61 - Code modifications that affect existing behavior
62 - Multi-file changes (touching more than 2-3 files)
63 - Unclear requirements that need exploration first
64
65 Do NOT use for: single-line fixes, typos, or purely research/exploration tasks.
66
67 The `description` parameter is used as the plan file name (e.g. "add-auth" → plan-add-auth.md).
68 If a plan file with the same name already exists, you will be warned so you can choose a different name.
69 Plan files are preserved after exiting plan mode for future reference.
70 "#.into()
71 }
72
73 fn parameters_schema(&self) -> Value {
74 schema_to_tool_params::<EnterPlanModeParams>()
75 }
76
77 fn execute(&self, arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
78 let params: EnterPlanModeParams =
79 serde_json::from_str(arguments).unwrap_or(EnterPlanModeParams { description: None });
80 let description = params
81 .description
82 .as_deref()
83 .unwrap_or("implementation-plan");
84
85 let plan_dir = JcliConfig::ensure_config_dir()
87 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join(".jcli"));
88 let plans_dir = plan_dir.join("plans");
89 let _ = std::fs::create_dir_all(&plans_dir);
90
91 let safe_name = sanitize_filename(description);
93 let file_name = if safe_name.is_empty() {
94 format!("plan-{}.md", std::process::id())
95 } else {
96 format!("plan-{}.md", safe_name)
97 };
98 let plan_file = plans_dir.join(&file_name);
99 let plan_path = plan_file.display().to_string();
100
101 let mut warning = String::new();
103 if plan_file.exists() {
104 match std::fs::read_to_string(&plan_file) {
105 Ok(existing) => {
106 let first_line = existing.lines().next().unwrap_or("");
108 warning = format!(
109 "⚠️ Plan file already exists: {} (content starts with: {})\n\
110 The existing file will be overwritten. Consider using a different description to avoid this.\n\n",
111 plan_path, first_line
112 );
113 }
114 Err(_) => {
115 warning = format!(
116 "⚠️ Plan file already exists: {}\n\
117 The existing file will be overwritten. Consider using a different description to avoid this.\n\n",
118 plan_path
119 );
120 }
121 }
122 }
123
124 let template = format!("# Plan: {}\n\n## Steps\n\n1. \n\n## Notes\n\n", description);
126 let _ = std::fs::write(&plan_file, &template);
127
128 match self.plan_state.enter(plan_path.clone()) {
130 Ok(()) => ToolResult {
131 output: format!(
132 "{}Entered plan mode. Plan file: {}\n\
133 In plan mode, only read-only tools are available.\n\
134 Write your plan to the plan file, then use ExitPlanMode when ready for user approval.\n\
135 Plan files are preserved after exit for future reference.",
136 warning, plan_path
137 ),
138 is_error: false,
139 images: vec![],
140 plan_decision: PlanDecision::None,
141 },
142 Err(msg) => ToolResult {
143 output: msg,
144 is_error: false,
145 images: vec![],
146 plan_decision: PlanDecision::None,
147 },
148 }
149 }
150
151 fn requires_confirmation(&self) -> bool {
152 false
153 }
154}
155
156#[derive(Deserialize, JsonSchema)]
160#[allow(dead_code)]
161struct ExitPlanModeParams {
162 #[serde(default)]
164 #[serde(rename = "allowedPrompts")]
165 allowed_prompts: Option<Vec<AllowedPrompt>>,
166}
167
168#[derive(Deserialize, JsonSchema)]
170#[allow(dead_code)]
171struct AllowedPrompt {
172 #[serde(default)]
174 tool: Option<String>,
175 #[serde(default)]
177 prompt: Option<String>,
178}
179
180pub struct ExitPlanModeTool {
182 pub plan_state: Arc<PlanModeState>,
184 pub ask_tx: mpsc::Sender<AskRequest>,
186 pub plan_approval_queue: Option<Arc<PlanApprovalQueue>>,
188}
189
190impl ExitPlanModeTool {
191 pub const NAME: &'static str = "ExitPlanMode";
192}
193
194impl Tool for ExitPlanModeTool {
195 fn name(&self) -> &str {
196 Self::NAME
197 }
198
199 fn description(&self) -> Cow<'_, str> {
200 r#"
201 Exit plan mode and submit the plan for user approval.
202 Reads the plan file and presents it to the user for review.
203 If approved, plan mode is deactivated and write tools become available again.
204 If rejected, plan mode remains active so you can revise the plan.
205 "#
206 .into()
207 }
208
209 fn parameters_schema(&self) -> Value {
210 schema_to_tool_params::<ExitPlanModeParams>()
211 }
212
213 fn execute(&self, _arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
214 if !self.plan_state.is_active() {
215 return ToolResult {
216 output: "Not in plan mode. Use EnterPlanMode first.".to_string(),
217 is_error: true,
218 images: vec![],
219 plan_decision: PlanDecision::None,
220 };
221 }
222
223 let plan_content = match self.plan_state.get_plan_file_path() {
225 Some(path) => match std::fs::read_to_string(&path) {
226 Ok(content) => content,
227 Err(e) => {
228 return ToolResult {
229 output: format!("Failed to read plan file: {}", e),
230 is_error: true,
231 images: vec![],
232 plan_decision: PlanDecision::None,
233 };
234 }
235 },
236 None => {
237 return ToolResult {
238 output: "No plan file path set.".to_string(),
239 is_error: true,
240 images: vec![],
241 plan_decision: PlanDecision::None,
242 };
243 }
244 };
245
246 let agent_name = current_agent_name();
248 if agent_name != "Main" {
249 if let Some(ref queue) = self.plan_approval_queue {
251 let plan_file_path = self.plan_state.get_plan_file_path().unwrap_or_default();
253 let plan_name = std::path::Path::new(&plan_file_path)
254 .file_stem()
255 .and_then(|s| s.to_str())
256 .and_then(|s| s.strip_prefix("plan-"))
257 .unwrap_or("Plan")
258 .to_string();
259
260 let req = PendingPlanApproval::new(agent_name, plan_content.clone(), plan_name);
261 let decision = queue.request_blocking(req);
262
263 match decision {
264 PlanDecision::Approve => {
265 let plan_file_path = self.plan_state.get_plan_file_path();
266 self.plan_state.exit();
267 let preserved_msg = plan_file_path
268 .as_deref()
269 .map(|p| format!("\nPlan file preserved at: {}", p))
270 .unwrap_or_default();
271 ToolResult {
272 output: format!(
273 "Plan approved! Exited plan mode. You can now proceed with implementation.{}\n\n**Plan Content:**\n\n{}",
274 preserved_msg, plan_content
275 ),
276 is_error: false,
277 images: vec![],
278 plan_decision: PlanDecision::Approve,
279 }
280 }
281 PlanDecision::ApproveAndClearContext => {
282 let plan_file_path = self.plan_state.get_plan_file_path();
283 self.plan_state.exit();
284 let preserved_msg = plan_file_path
285 .as_deref()
286 .map(|p| format!("\nPlan file preserved at: {}", p))
287 .unwrap_or_default();
288 ToolResult {
289 output: format!(
290 "Plan approved with context clear! Exited plan mode.{}\n\n**Plan Content:**\n\n{}",
291 preserved_msg, plan_content
292 ),
293 is_error: false,
294 images: vec![],
295 plan_decision: PlanDecision::ApproveAndClearContext,
296 }
297 }
298 PlanDecision::Reject | PlanDecision::None => {
299 ToolResult {
300 output: "Plan was not approved. Still in plan mode. Please revise your plan and try ExitPlanMode again.".to_string(),
301 is_error: false,
302 images: vec![],
303 plan_decision: PlanDecision::Reject,
304 }
305 }
306 }
307 } else {
308 ToolResult {
310 output: "Plan approval not available in sub-agent mode (no queue). Add permission rules to avoid plan mode in teammates.".to_string(),
311 is_error: true,
312 images: vec![],
313 plan_decision: PlanDecision::None,
314 }
315 }
316 } else {
317 self.execute_via_ask_tx(&plan_content)
319 }
320 }
321
322 fn requires_confirmation(&self) -> bool {
323 false
324 }
325}
326
327impl ExitPlanModeTool {
328 fn execute_via_ask_tx(&self, plan_content: &str) -> ToolResult {
330 let (response_tx, response_rx) = mpsc::channel::<String>();
332
333 let question_text = format!("请审阅以下实施计划,选择操作:\n\n{}", plan_content);
334
335 let plan_file_path = self.plan_state.get_plan_file_path().unwrap_or_default();
337 let plan_name = std::path::Path::new(&plan_file_path)
338 .file_stem()
339 .and_then(|s| s.to_str())
340 .and_then(|s| s.strip_prefix("plan-"))
341 .unwrap_or("Plan");
342
343 let ask_request = AskRequest {
344 questions: vec![AskQuestion {
345 question: question_text,
346 header: plan_name.to_string(),
347 options: vec![
348 AskOption {
349 label: "批准计划".to_string(),
350 description: "批准此计划,保留当前上下文,开始实施".to_string(),
351 },
352 AskOption {
353 label: "批准并清空上下文".to_string(),
354 description: "批准计划并清空探索过程中的对话上下文,仅保留计划内容继续实施"
355 .to_string(),
356 },
357 AskOption {
358 label: "驳回计划".to_string(),
359 description: "拒绝此计划,留在 Plan Mode 中修改方案".to_string(),
360 },
361 ],
362 multi_select: false,
363 }],
364 response_tx,
365 };
366
367 if self.ask_tx.send(ask_request).is_err() {
368 return ToolResult {
369 output: "Failed to send approval request (main thread may have exited)".to_string(),
370 is_error: true,
371 images: vec![],
372 plan_decision: PlanDecision::None,
373 };
374 }
375
376 match response_rx.recv() {
378 Ok(response) => {
379 if response.contains("批准并清空上下文") {
380 let plan_file_path = self.plan_state.get_plan_file_path();
381 self.plan_state.exit();
382 let preserved_msg = plan_file_path
383 .as_deref()
384 .map(|p| format!("\nPlan file preserved at: {}", p))
385 .unwrap_or_default();
386 ToolResult {
387 output: format!(
388 "Plan approved with context clear! Exited plan mode.{}\n\n**Plan Content:**\n\n{}",
389 preserved_msg, plan_content
390 ),
391 is_error: false,
392 images: vec![],
393 plan_decision: PlanDecision::ApproveAndClearContext,
394 }
395 } else if response.contains("批准") {
396 let plan_file_path = self.plan_state.get_plan_file_path();
397 self.plan_state.exit();
398 let preserved_msg = plan_file_path
399 .as_deref()
400 .map(|p| format!("\nPlan file preserved at: {}", p))
401 .unwrap_or_default();
402 ToolResult {
403 output: format!(
404 "Plan approved! Exited plan mode. You can now proceed with implementation.{}\n\n**Plan Content:**\n\n{}",
405 preserved_msg, plan_content
406 ),
407 is_error: false,
408 images: vec![],
409 plan_decision: PlanDecision::Approve,
410 }
411 } else {
412 ToolResult {
414 output: format!(
415 "Plan was not approved. Still in plan mode. User response: {}\nPlease revise your plan and try ExitPlanMode again.",
416 response
417 ),
418 is_error: false,
419 images: vec![],
420 plan_decision: PlanDecision::Reject,
421 }
422 }
423 }
424 Err(_) => ToolResult {
425 output: "Connection lost while waiting for approval".to_string(),
426 is_error: true,
427 images: vec![],
428 plan_decision: PlanDecision::None,
429 },
430 }
431 }
432}