use agent_client_protocol as acp; use kigi_acp_lib::AcpResult; pub use kigi_tools::implementations::kigi::exit_plan_mode::{ ExitPlanModeExtRequest, ExitPlanModeExtResponse, }; use crate::views::prompt_widget::StashedPrompt; /// Placeholder body for the plan-approval preview when `exit_plan_mode` parks /// with no plan content (missing/empty `plan.md`, or a whitespace-only body). /// /// Must be non-empty after trim so `LineViewerState::open_markdown_content` /// accepts it — empty bodies are rejected there. pub const EMPTY_PLAN_PLACEHOLDER: &str = "\ # No plan written yet The agent exited plan mode without writing a plan. - **Approve** — leave plan mode and start implementing - **Request changes** — send the agent back to planning - **Quit** — abandon and turn plan mode off "; /// Status-line label while plan approval is parked. /// /// Empty plans use an active decision prompt instead of "Waiting…", so the /// UI doesn't look stuck when there is no preview body to open. pub fn plan_approval_status_label(has_plan: bool) -> &'static str { if has_plan { "Waiting on plan approval" } else { "No plan written — approve or request changes" } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlanApprovalFocus { Preview, Prompt, Commenting, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlanReviewSource { Inline, FileBacked, } #[derive(Debug, Clone)] pub struct PlanComment { pub id: u64, pub line_range: std::ops::Range, pub text: String, } pub struct PlanApprovalViewState { pub tool_call_id: String, pub has_plan: bool, pub plan_content: Option, pub source: PlanReviewSource, pub stashed_prompt: StashedPrompt, pub response_tx: Option>>, pub focus: PlanApprovalFocus, pub comments: Vec, pub next_comment_id: u64, pub editing_comment_id: Option, pub commenting_range: Option>, pub stashed_feedback_prompt: Option, } impl PlanApprovalViewState { pub fn new( request: ExitPlanModeExtRequest, stashed_prompt: StashedPrompt, response_tx: tokio::sync::oneshot::Sender>, ) -> Self { Self::with_source( request, PlanReviewSource::Inline, stashed_prompt, response_tx, ) } pub fn with_source( request: ExitPlanModeExtRequest, source: PlanReviewSource, stashed_prompt: StashedPrompt, response_tx: tokio::sync::oneshot::Sender>, ) -> Self { let plan_content = request.plan_content.filter(|s| !s.trim().is_empty()); let has_plan = plan_content.is_some(); Self { tool_call_id: request.tool_call_id, has_plan, plan_content, source, stashed_prompt, response_tx: Some(response_tx), focus: PlanApprovalFocus::Preview, comments: Vec::new(), next_comment_id: 0, editing_comment_id: None, commenting_range: None, stashed_feedback_prompt: None, } } pub fn format_feedback(&self, freeform: Option<&str>) -> String { let mut parts: Vec = self .comments .iter() .map(|comment| match self.source { PlanReviewSource::Inline => { let label = if comment.line_range.len() == 1 { format!("Proposed plan line {}:", comment.line_range.start) } else { format!( "Proposed plan lines {}-{}:", comment.line_range.start, comment.line_range.end - 1 ) }; let snippets = inline_plan_snippets(self.plan_content.as_deref(), &comment.line_range); format!("{label}\n{snippets}\n\nComment:\n{}", comment.text) } PlanReviewSource::FileBacked => format_file_backed_plan_comment(comment), }) .collect(); if let Some(text) = freeform && !text.trim().is_empty() { let text = match (self.source, self.comments.is_empty()) { (PlanReviewSource::Inline, false) => format!("Additional feedback:\n{text}"), _ => text.to_owned(), }; parts.push(text); } parts.join("\n\n") } } pub fn send_exit_plan_response( tx: tokio::sync::oneshot::Sender>, outcome: &str, feedback: Option, ) { let feedback = feedback.filter(|f| !f.trim().is_empty()); let resp = ExitPlanModeExtResponse { outcome: outcome.into(), feedback, }; let raw = serde_json::value::to_raw_value(&resp) .expect("ExitPlanModeExtResponse serialization should not fail"); tx.send(Ok(acp::ExtResponse::new(raw.into()))).ok(); } fn send_ext_response( tx: &mut Option>>, outcome: &str, feedback: Option, ) -> bool { let Some(tx) = tx.take() else { return false; }; send_exit_plan_response(tx, outcome, feedback); true } impl PlanApprovalViewState { pub fn send_approved(&mut self) -> bool { send_ext_response(&mut self.response_tx, "approved", None) } pub fn send_abandoned(&mut self) -> bool { send_ext_response(&mut self.response_tx, "abandoned", None) } pub fn send_cancelled(&mut self, feedback: Option) -> bool { send_ext_response(&mut self.response_tx, "cancelled", feedback) } pub fn send_stale_cancel(&mut self) -> bool { self.send_cancelled(None) } } fn format_file_backed_plan_comment(comment: &PlanComment) -> String { let range = if comment.line_range.len() == 1 { format!("@plan.md:{}", comment.line_range.start) } else { format!( "@plan.md:{}-{}", comment.line_range.start, comment.line_range.end - 1 ) }; format!("{range}\n{}", comment.text) } pub(crate) fn inline_plan_snippets( plan_content: Option<&str>, range: &std::ops::Range, ) -> String { let Some(plan_content) = plan_content else { return "> [plan content unavailable]".to_owned(); }; let lines: Vec<&str> = plan_content.lines().collect(); if range.start == 0 || range.start >= range.end || range.start > lines.len() { return "> [selected lines unavailable]".to_owned(); } let end = range.end.saturating_sub(1).min(lines.len()); if end < range.start { return "> [selected lines unavailable]".to_owned(); } lines[range.start - 1..end] .iter() .map(|line| format!("> {line}")) .collect::>() .join("\n") } pub(crate) fn format_plan_comments(comments: &[PlanComment], plan_content: Option<&str>) -> String { comments .iter() .map(|comment| { let label = if comment.line_range.len() == 1 { format!("Proposed plan line {}:", comment.line_range.start) } else { format!( "Proposed plan lines {}-{}:", comment.line_range.start, comment.line_range.end - 1 ) }; let snippets = inline_plan_snippets(plan_content, &comment.line_range); format!("{label}\n{snippets}\n\nComment:\n{}", comment.text) }) .collect::>() .join("\n\n") } #[cfg(test)] mod tests { use super::*; fn make_test_state() -> ( PlanApprovalViewState, tokio::sync::oneshot::Receiver>, ) { let (tx, rx) = tokio::sync::oneshot::channel(); let request = ExitPlanModeExtRequest { session_id: "test-session".into(), tool_call_id: "call_123".into(), plan_content: Some("# Plan\n\n## Step 1\nDo something".into()), }; let state = PlanApprovalViewState::new( request, StashedPrompt { text: "stashed text".into(), cursor: 0, images: Vec::new(), chip_elements: Vec::new(), image_counter: 0, image_undo_stash: Vec::new(), }, tx, ); (state, rx) } #[test] fn test_send_approved() { let (mut state, mut rx) = make_test_state(); assert!(state.send_approved()); let resp = rx.try_recv().expect("should receive response"); let raw = resp.expect("should be Ok"); let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).expect("should be valid JSON"); assert_eq!(parsed["outcome"], "approved"); assert!(parsed.get("feedback").is_none()); } #[test] fn test_send_cancelled_with_feedback() { let (mut state, mut rx) = make_test_state(); assert!(state.send_cancelled(Some("fix auth flow".into()))); let resp = rx.try_recv().expect("should receive response"); let raw = resp.expect("should be Ok"); let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).expect("should be valid JSON"); assert_eq!(parsed["outcome"], "cancelled"); assert_eq!(parsed["feedback"], "fix auth flow"); } #[test] fn test_send_cancelled_without_feedback() { let (mut state, mut rx) = make_test_state(); assert!(state.send_cancelled(None)); let resp = rx.try_recv().expect("should receive response"); let raw = resp.expect("should be Ok"); let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).expect("should be valid JSON"); assert_eq!(parsed["outcome"], "cancelled"); assert!(parsed.get("feedback").is_none()); } #[test] fn test_send_cancelled_empty_feedback_is_none() { let (mut state, mut rx) = make_test_state(); assert!(state.send_cancelled(Some(" ".into()))); let resp = rx.try_recv().expect("should receive response"); let raw = resp.expect("should be Ok"); let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).expect("should be valid JSON"); assert_eq!(parsed["outcome"], "cancelled"); assert!(parsed.get("feedback").is_none()); } #[test] fn test_send_stale_cancel() { let (mut state, mut rx) = make_test_state(); assert!(state.send_stale_cancel()); let resp = rx.try_recv().expect("should receive response"); let raw = resp.expect("should be Ok"); let parsed: serde_json::Value = serde_json::from_str(raw.0.get()).expect("should be valid JSON"); assert_eq!(parsed["outcome"], "cancelled"); assert!(parsed.get("feedback").is_none()); } #[test] fn test_double_send_returns_false() { let (mut state, _rx) = make_test_state(); assert!(state.send_approved()); assert!(!state.send_approved()); assert!(!state.send_cancelled(None)); } #[test] fn test_constructor_defaults() { let (state, _rx) = make_test_state(); assert_eq!(state.tool_call_id, "call_123"); assert!(state.has_plan); assert_eq!( state.plan_content.as_deref(), Some("# Plan\n\n## Step 1\nDo something") ); assert_eq!(state.source, PlanReviewSource::Inline); assert_eq!(state.stashed_prompt.text, "stashed text"); assert!(state.response_tx.is_some()); assert_eq!(state.focus, PlanApprovalFocus::Preview); assert!(state.comments.is_empty()); assert_eq!(state.next_comment_id, 0); assert!(state.editing_comment_id.is_none()); assert!(state.commenting_range.is_none()); assert!(state.stashed_feedback_prompt.is_none()); } fn make_empty_plan_state() -> ( PlanApprovalViewState, tokio::sync::oneshot::Receiver>, ) { let (tx, rx) = tokio::sync::oneshot::channel(); let request = ExitPlanModeExtRequest { session_id: "test-session".into(), tool_call_id: "call_456".into(), plan_content: None, }; let state = PlanApprovalViewState::new( request, StashedPrompt { text: "stashed".into(), cursor: 0, images: Vec::new(), chip_elements: Vec::new(), image_counter: 0, image_undo_stash: Vec::new(), }, tx, ); (state, rx) } #[test] fn test_empty_plan_has_plan_false() { let (state, _rx) = make_empty_plan_state(); assert!(!state.has_plan); assert!(state.plan_content.is_none()); } #[test] fn plan_approval_status_label_distinguishes_empty() { assert_eq!(plan_approval_status_label(true), "Waiting on plan approval"); assert_eq!( plan_approval_status_label(false), "No plan written — approve or request changes" ); // Placeholder must be non-empty so the line viewer accepts it. assert!(!EMPTY_PLAN_PLACEHOLDER.trim().is_empty()); } #[test] fn test_empty_plan_whitespace_only() { let (tx, _rx) = tokio::sync::oneshot::channel(); let request = ExitPlanModeExtRequest { session_id: "test-session".into(), tool_call_id: "call_789".into(), plan_content: Some(" \n\n ".into()), }; let state = PlanApprovalViewState::new( request, StashedPrompt { text: "stashed".into(), cursor: 0, images: Vec::new(), chip_elements: Vec::new(), image_counter: 0, image_undo_stash: Vec::new(), }, tx, ); assert!(!state.has_plan); assert!(state.plan_content.is_none()); } #[test] fn inline_plan_feedback_quotes_selected_line_snippets() { let (mut state, _rx) = make_test_state(); state.plan_content = Some("alpha\nbravo\ncharlie\ndelta".into()); state.comments.push(PlanComment { id: 0, line_range: 2..3, text: "rewrite this".into(), }); state.comments.push(PlanComment { id: 1, line_range: 3..5, text: "combine these".into(), }); let feedback = state.format_feedback(Some("overall note")); assert_eq!( feedback, "Proposed plan line 2:\n> bravo\n\nComment:\nrewrite this\n\nProposed plan lines 3-4:\n> charlie\n> delta\n\nComment:\ncombine these\n\nAdditional feedback:\noverall note" ); } #[test] fn inline_plan_feedback_handles_out_of_range_lines() { let (mut state, _rx) = make_test_state(); state.plan_content = Some("alpha".into()); state.comments.push(PlanComment { id: 0, line_range: 9..10, text: "where is this".into(), }); assert_eq!( state.format_feedback(None), "Proposed plan line 9:\n> [selected lines unavailable]\n\nComment:\nwhere is this" ); } #[test] fn file_backed_plan_feedback_keeps_plan_md_references() { let (mut state, _rx) = make_test_state(); state.source = PlanReviewSource::FileBacked; state.plan_content = Some("alpha\nbravo".into()); state.comments.push(PlanComment { id: 0, line_range: 1..3, text: "keep file ref".into(), }); assert_eq!( state.format_feedback(Some("freeform")), "@plan.md:1-2\nkeep file ref\n\nfreeform" ); } }