Skip to main content

wisp/components/app/
plan_review_mode.rs

1use 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}