返回 DeepSeek-TUI-2026
plan_prompt.rs
根目录 / crates / tui / src / tui / plan_prompt.rs
1 //! Modal prompt for selecting what to do after a plan is generated.
2
3 use crossterm::event::{KeyCode, KeyEvent};
4 use ratatui::layout::{Alignment, Rect};
5 use ratatui::prelude::*;
6 use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};
7
8 use crate::palette;
9 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
10
11 const PLAN_OPTIONS: [(&str, &str); 4] = [
12 (
13 "Accept plan (Agent)",
14 "Start implementation in Agent mode with approvals",
15 ),
16 (
17 "Accept plan (YOLO)",
18 "Start implementation in YOLO mode (auto-approve)",
19 ),
20 ("Revise plan", "Ask follow-ups or request plan changes"),
21 (
22 "Exit Plan mode",
23 "Return to Agent mode without implementation",
24 ),
25 ];
26
27 fn modal_block() -> Block<'static> {
28 Block::default()
29 .title(Line::from(vec![Span::styled(
30 " Plan Confirmation ",
31 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
32 )]))
33 .borders(Borders::ALL)
34 .border_style(Style::default().fg(palette::BORDER_COLOR))
35 .padding(Padding::uniform(1))
36 }
37
38 fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
39 let shadow_x = popup_area.x.saturating_add(1);
40 let shadow_y = popup_area.y.saturating_add(1);
41 let shadow_right = area.x.saturating_add(area.width);
42 let shadow_bottom = area.y.saturating_add(area.height);
43 let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x));
44 let shadow_height = popup_area
45 .height
46 .min(shadow_bottom.saturating_sub(shadow_y));
47
48 if shadow_width > 0 && shadow_height > 0 {
49 Block::default().render(
50 Rect {
51 x: shadow_x,
52 y: shadow_y,
53 width: shadow_width,
54 height: shadow_height,
55 },
56 buf,
57 );
58 }
59
60 Clear.render(popup_area, buf);
61 }
62
63 fn push_option_lines(
64 lines: &mut Vec<Line<'static>>,
65 selected: bool,
66 number: usize,
67 label: &str,
68 description: &str,
69 ) {
70 let row_style = if selected {
71 Style::default()
72 .fg(palette::SELECTION_TEXT)
73 .bg(palette::SELECTION_BG)
74 .bold()
75 } else {
76 Style::default().fg(palette::TEXT_PRIMARY)
77 };
78 let detail_style = if selected {
79 row_style
80 } else {
81 Style::default().fg(palette::TEXT_MUTED)
82 };
83 let prefix = if selected { ">" } else { " " };
84
85 lines.push(Line::from(Span::styled(
86 format!("{prefix} {number}) {label}"),
87 row_style,
88 )));
89 lines.push(Line::from(Span::styled(
90 format!(" {description}"),
91 detail_style,
92 )));
93 }
94
95 #[derive(Debug, Clone, Default)]
96 pub struct PlanPromptView {
97 selected: usize,
98 }
99
100 impl PlanPromptView {
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 fn max_index(&self) -> usize {
106 PLAN_OPTIONS.len().saturating_sub(1)
107 }
108
109 fn submit_selected(&self) -> ViewAction {
110 ViewAction::EmitAndClose(ViewEvent::PlanPromptSelected {
111 option: self.selected + 1,
112 })
113 }
114
115 fn submit_number(number: u32) -> ViewAction {
116 if (1..=u32::try_from(PLAN_OPTIONS.len()).unwrap_or(0)).contains(&number) {
117 ViewAction::EmitAndClose(ViewEvent::PlanPromptSelected {
118 option: usize::try_from(number).unwrap_or(1),
119 })
120 } else {
121 ViewAction::None
122 }
123 }
124 }
125
126 impl ModalView for PlanPromptView {
127 fn kind(&self) -> ModalKind {
128 ModalKind::PlanPrompt
129 }
130
131 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
132 self
133 }
134
135 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
136 match key.code {
137 KeyCode::Up | KeyCode::Char('k') => {
138 self.selected = self.selected.saturating_sub(1);
139 ViewAction::None
140 }
141 KeyCode::Down | KeyCode::Char('j') => {
142 self.selected = (self.selected + 1).min(self.max_index());
143 ViewAction::None
144 }
145 KeyCode::Char('1') => {
146 self.selected = 0;
147 self.submit_selected()
148 }
149 KeyCode::Char('2') => {
150 self.selected = 1;
151 self.submit_selected()
152 }
153 KeyCode::Char('3') => {
154 self.selected = 2;
155 self.submit_selected()
156 }
157 KeyCode::Char('4') => {
158 self.selected = 3;
159 self.submit_selected()
160 }
161 KeyCode::Char('a') | KeyCode::Char('A') => {
162 self.selected = 0;
163 self.submit_selected()
164 }
165 KeyCode::Char('y') | KeyCode::Char('Y') => {
166 self.selected = 1;
167 self.submit_selected()
168 }
169 KeyCode::Char('r') | KeyCode::Char('R') => {
170 self.selected = 2;
171 self.submit_selected()
172 }
173 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('e') | KeyCode::Char('E') => {
174 self.selected = 3;
175 self.submit_selected()
176 }
177 KeyCode::Char(ch) if ch.is_ascii_digit() => {
178 let number = ch.to_digit(10).unwrap_or(0);
179 Self::submit_number(number)
180 }
181 KeyCode::Enter => self.submit_selected(),
182 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed),
183 _ => ViewAction::None,
184 }
185 }
186
187 fn render(&self, area: Rect, buf: &mut Buffer) {
188 let mut lines: Vec<Line> = Vec::new();
189 lines.push(Line::from(vec![Span::styled(
190 "Action required",
191 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
192 )]));
193 lines.push(Line::from(vec![Span::styled(
194 "Choose what should happen after this plan.",
195 Style::default().fg(palette::TEXT_PRIMARY).bold(),
196 )]));
197 lines.push(Line::from(""));
198
199 for (idx, (label, description)) in PLAN_OPTIONS.iter().enumerate() {
200 let number = idx + 1;
201 push_option_lines(&mut lines, self.selected == idx, number, label, description);
202 }
203
204 lines.push(Line::from(""));
205 lines.push(Line::from(vec![
206 Span::styled(
207 "1-4 / a / y / r / q",
208 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
209 ),
210 Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
211 Span::raw(" "),
212 Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
213 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
214 Span::raw(" "),
215 Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
216 Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
217 Span::raw(" "),
218 Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
219 Span::styled(" close", Style::default().fg(palette::TEXT_MUTED)),
220 ]));
221
222 let paragraph = Paragraph::new(lines)
223 .alignment(Alignment::Left)
224 .wrap(Wrap { trim: true })
225 .block(modal_block());
226
227 let popup_area = centered_rect(72, 52, area);
228 render_modal_chrome(area, popup_area, buf);
229 paragraph.render(popup_area, buf);
230 }
231 }
232
233 fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
234 let popup_layout = Layout::default()
235 .direction(Direction::Vertical)
236 .constraints([
237 Constraint::Percentage((100 - percent_y) / 2),
238 Constraint::Percentage(percent_y),
239 Constraint::Percentage((100 - percent_y) / 2),
240 ])
241 .split(area);
242
243 Layout::default()
244 .direction(Direction::Horizontal)
245 .constraints([
246 Constraint::Percentage((100 - percent_x) / 2),
247 Constraint::Percentage(percent_x),
248 Constraint::Percentage((100 - percent_x) / 2),
249 ])
250 .split(popup_layout[1])[1]
251 }
252
253 #[cfg(test)]
254 mod tests {
255 use super::*;
256
257 fn render_view(view: &PlanPromptView, width: u16, height: u16) -> String {
258 let area = Rect::new(0, 0, width, height);
259 let mut buf = Buffer::empty(area);
260 view.render(area, &mut buf);
261
262 (0..height)
263 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
264 .collect::<Vec<_>>()
265 .join("\n")
266 }
267
268 #[test]
269 fn plan_prompt_calls_out_required_action_and_controls() {
270 let rendered = render_view(&PlanPromptView::new(), 110, 36);
271
272 assert!(rendered.contains("Action required"));
273 assert!(rendered.contains("Choose what should happen after this plan."));
274 assert!(rendered.contains("1-4"));
275 assert!(rendered.contains("Enter"));
276 }
277
278 #[test]
279 fn plan_prompt_keeps_selected_option_and_description_together() {
280 let mut view = PlanPromptView::new();
281 view.selected = 1;
282
283 let rendered = render_view(&view, 110, 36);
284
285 assert!(rendered.contains("> 2) Accept plan (YOLO)"));
286 assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)"));
287 }
288 }
289
289 lines RUST