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