wisp/components/app/
plan_review_mode.rs1use crate::components::common::render_key_hints;
2use crate::components::plan_review::{OutlinePanel, OutlinePanelMessage, PlanDocument, PlanPanel};
3use crate::components::review_comments::{CommentAnchor, ReviewComment};
4use std::fmt::Write;
5use tui::{Component, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, ViewContext};
6
7pub enum PlanReviewAction {
8 Approve,
9 RequestChanges { feedback: String },
10 Cancel,
11}
12
13pub struct PlanReviewInput {
14 pub title: String,
15 pub document: PlanDocument,
16}
17
18pub struct PlanReviewMode {
19 title: String,
20 split: SplitPanel<OutlinePanel, PlanPanel>,
21}
22
23impl PlanReviewMode {
24 pub fn new(input: PlanReviewInput) -> Self {
25 let PlanReviewInput { title, document } = input;
26 let outline_panel = OutlinePanel::new(document.outline.clone());
27 let plan_panel = PlanPanel::new(document);
28 let mut split = SplitPanel::new(outline_panel, plan_panel, SplitLayout::fraction(1, 4, 20, 32))
29 .with_separator("│", Style::default())
30 .with_resize_keys();
31
32 split.focus_right();
33 Self { title, split }
34 }
35
36 pub fn current_source_line_no(&self) -> usize {
37 self.split.right().current_source_line_no()
38 }
39
40 pub fn comment_count(&self) -> usize {
41 self.split.right().comment_count()
42 }
43}
44
45impl Component for PlanReviewMode {
46 type Message = PlanReviewAction;
47
48 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
49 if let Event::Key(key) = event
50 && !self.split.right().is_in_comment_mode()
51 {
52 match key.code {
53 KeyCode::Esc => return Some(vec![PlanReviewAction::Cancel]),
54 KeyCode::Char('a') => return Some(vec![PlanReviewAction::Approve]),
55 KeyCode::Char('r') => {
56 let plan_panel = self.split.right();
57 let feedback = compile_feedback(plan_panel.document(), plan_panel.comments());
58 return Some(vec![PlanReviewAction::RequestChanges { feedback }]);
59 }
60 KeyCode::Char('u') => {
61 self.split.right_mut().undo_last_comment();
62 return Some(vec![]);
63 }
64 KeyCode::Char('n') => {
65 self.split.right_mut().jump_next_heading();
66 return Some(vec![]);
67 }
68 KeyCode::Char('p') => {
69 self.split.right_mut().jump_prev_heading();
70 return Some(vec![]);
71 }
72 KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
73 self.split.focus_left();
74 return Some(vec![]);
75 }
76 KeyCode::Char('l') | KeyCode::Right if self.split.is_left_focused() => {
77 self.split.focus_right();
78 return Some(vec![]);
79 }
80 _ => {}
81 }
82 }
83
84 let split_messages = self.split.on_event(event).await?;
85 for message in split_messages {
86 match message {
87 Either::Left(OutlinePanelMessage::OpenSelectedAnchor(anchor_line_no)) => {
88 self.split.right_mut().set_cursor_source_line_no(anchor_line_no);
89 self.split.focus_right();
90 }
91 Either::Right(message) => match message {},
92 }
93 }
94
95 Some(vec![])
96 }
97
98 fn render(&mut self, ctx: &ViewContext) -> Frame {
99 if ctx.size.width < 20 {
100 return Frame::new(vec![Line::new("Plan review view is too narrow")]);
101 }
102
103 let plan_focused = !self.split.is_left_focused();
104 let title_fg = if plan_focused { ctx.theme.accent() } else { ctx.theme.text_primary() };
105 let mut header = Line::default();
106 header.push_with_style(self.title.as_str(), Style::fg(title_fg).bold());
107
108 let body_height = ctx.size.height.saturating_sub(2);
109 let body_context = ctx.with_size((ctx.size.width, body_height));
110
111 self.split.left_mut().set_focused(!plan_focused);
112 self.split.set_separator_style(Style::fg(ctx.theme.muted()));
113 let body = self.split.render(&body_context);
114
115 let help_keys: &[(&str, &str)] = if plan_focused { &PLAN_HELP_KEYS } else { &OUTLINE_HELP_KEYS };
116 Frame::vstack([Frame::new(vec![header]), body, Frame::new(vec![render_key_hints(&ctx.theme, help_keys)])])
117 }
118}
119
120const OUTLINE_HELP_KEYS: [(&str, &str); 6] =
121 [("j/k", "move"), ("g/G", "top/bottom"), ("enter", "jump"), ("h/l", "focus"), ("u", "undo"), ("Esc", "cancel")];
122
123const PLAN_HELP_KEYS: [(&str, &str); 8] = [
124 ("j/k", "move"),
125 ("n/p", "heading"),
126 ("h", "outline"),
127 ("c", "comment"),
128 ("u", "undo"),
129 ("a", "approve"),
130 ("r", "changes"),
131 ("Esc", "cancel"),
132];
133
134fn compile_feedback(document: &PlanDocument, comments: &[ReviewComment<usize>]) -> String {
135 if comments.is_empty() {
136 return "Plan needs changes, but no inline comments were provided.".to_string();
137 }
138
139 let mut output = String::from("# Plan review feedback\n\n");
140 let mut current_section: Option<usize> = None;
141
142 for comment in comments {
143 let CommentAnchor(line_no) = comment.anchor;
144 let Some(line) = document.line_by_no(line_no) else {
145 continue;
146 };
147
148 if line.section_index != current_section {
149 if let Some(section_title) = document.section_title_for(line) {
150 let _ = writeln!(output, "## {section_title}");
151 output.push('\n');
152 }
153 current_section = line.section_index;
154 }
155
156 let _ = writeln!(output, "### Line {}", line.line_no);
157 if !line.text.trim().is_empty() {
158 let _ = writeln!(output, "`{}`", sanitize_line_snippet(&line.text));
159 }
160
161 let mut wrote_point = false;
162 for feedback_line in comment.body.lines().map(str::trim).filter(|line| !line.is_empty()) {
163 let _ = writeln!(output, "- {feedback_line}");
164 wrote_point = true;
165 }
166
167 if !wrote_point {
168 output.push_str("- (no comment text provided)\n");
169 }
170
171 output.push('\n');
172 }
173
174 if output.trim() == "# Plan review feedback" {
175 "Plan needs changes, but no inline comments were provided.".to_string()
176 } else {
177 output.trim().to_string()
178 }
179}
180
181fn sanitize_line_snippet(line: &str) -> String {
182 let mut trimmed = line.trim().replace('`', "\\`");
183 if trimmed.chars().count() > 140 {
184 trimmed = trimmed.chars().take(137).collect::<String>() + "...";
185 }
186 trimmed
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn compile_feedback_falls_back_when_no_comments() {
195 let document = PlanDocument::parse("/tmp/plan.md", "# Plan");
196 let feedback = compile_feedback(&document, &[]);
197 assert!(feedback.contains("no inline comments"));
198 }
199
200 #[test]
201 fn compile_feedback_includes_line_numbers_and_comments() {
202 let document = PlanDocument::parse("/tmp/plan.md", "# Overview\nline");
203 let comments = vec![ReviewComment::new(CommentAnchor(2), "Please expand this")];
204
205 let feedback = compile_feedback(&document, &comments);
206 assert!(feedback.contains("Line 2"));
207 assert!(feedback.contains("Please expand this"));
208 }
209}