返回 DeepSeek-TUI-2026
user_input.rs
根目录 / crates / tui / src / tui / user_input.rs
1 //! Modal for request_user_input tool prompts.
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::tools::user_input::{
10 UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
11 };
12 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
13
14 fn modal_block(title: &str) -> Block<'static> {
15 Block::default()
16 .title(Line::from(vec![Span::styled(
17 title.to_string(),
18 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
19 )]))
20 .borders(Borders::ALL)
21 .border_style(Style::default().fg(palette::BORDER_COLOR))
22 .padding(Padding::uniform(1))
23 }
24
25 fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
26 let shadow_x = popup_area.x.saturating_add(1);
27 let shadow_y = popup_area.y.saturating_add(1);
28 let shadow_right = area.x.saturating_add(area.width);
29 let shadow_bottom = area.y.saturating_add(area.height);
30 let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x));
31 let shadow_height = popup_area
32 .height
33 .min(shadow_bottom.saturating_sub(shadow_y));
34
35 if shadow_width > 0 && shadow_height > 0 {
36 Block::default().render(
37 Rect {
38 x: shadow_x,
39 y: shadow_y,
40 width: shadow_width,
41 height: shadow_height,
42 },
43 buf,
44 );
45 }
46
47 Clear.render(popup_area, buf);
48 }
49
50 fn push_option_lines(
51 lines: &mut Vec<Line<'static>>,
52 selected: bool,
53 number: usize,
54 label: String,
55 description: String,
56 ) {
57 let row_style = if selected {
58 Style::default()
59 .fg(palette::SELECTION_TEXT)
60 .bg(palette::SELECTION_BG)
61 .bold()
62 } else {
63 Style::default().fg(palette::TEXT_PRIMARY)
64 };
65 let detail_style = if selected {
66 row_style
67 } else {
68 Style::default().fg(palette::TEXT_MUTED)
69 };
70 let prefix = if selected { ">" } else { " " };
71
72 lines.push(Line::from(Span::styled(
73 format!("{prefix} {number}) {label}"),
74 row_style,
75 )));
76 lines.push(Line::from(Span::styled(
77 format!(" {description}"),
78 detail_style,
79 )));
80 }
81
82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 enum InputMode {
84 Selecting,
85 OtherInput,
86 }
87
88 #[derive(Debug, Clone)]
89 pub struct UserInputView {
90 tool_id: String,
91 request: UserInputRequest,
92 question_index: usize,
93 selected: usize,
94 mode: InputMode,
95 other_input: String,
96 answers: Vec<UserInputAnswer>,
97 }
98
99 impl UserInputView {
100 pub fn new(tool_id: impl Into<String>, request: UserInputRequest) -> Self {
101 Self {
102 tool_id: tool_id.into(),
103 request,
104 question_index: 0,
105 selected: 0,
106 mode: InputMode::Selecting,
107 other_input: String::new(),
108 answers: Vec::new(),
109 }
110 }
111
112 fn current_question(&self) -> &UserInputQuestion {
113 &self.request.questions[self.question_index]
114 }
115
116 fn option_count(&self) -> usize {
117 self.current_question().options.len() + 1
118 }
119
120 fn is_other_selected(&self) -> bool {
121 self.selected + 1 == self.option_count()
122 }
123
124 fn advance_question(&mut self, answer: UserInputAnswer) -> ViewAction {
125 self.answers.push(answer);
126 if self.question_index + 1 >= self.request.questions.len() {
127 let response = UserInputResponse {
128 answers: self.answers.clone(),
129 };
130 return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
131 tool_id: self.tool_id.clone(),
132 response,
133 });
134 }
135 self.question_index += 1;
136 self.selected = 0;
137 self.mode = InputMode::Selecting;
138 self.other_input.clear();
139 ViewAction::None
140 }
141
142 fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
143 match key.code {
144 KeyCode::Up | KeyCode::Char('k') => {
145 self.selected = self.selected.saturating_sub(1);
146 ViewAction::None
147 }
148 KeyCode::Down | KeyCode::Char('j') => {
149 self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1));
150 ViewAction::None
151 }
152 KeyCode::Char(ch) if ch.is_ascii_digit() => {
153 let Some(number) = ch.to_digit(10) else {
154 return ViewAction::None;
155 };
156 if number == 0 {
157 return ViewAction::None;
158 }
159 let index = usize::try_from(number - 1).unwrap_or(usize::MAX);
160 if index >= self.option_count() {
161 return ViewAction::None;
162 }
163 self.selected = index;
164 if self.is_other_selected() {
165 self.mode = InputMode::OtherInput;
166 self.other_input.clear();
167 ViewAction::None
168 } else {
169 let question = self.current_question();
170 let option = &question.options[self.selected];
171 let answer = UserInputAnswer {
172 id: question.id.clone(),
173 label: option.label.clone(),
174 value: option.label.clone(),
175 };
176 self.advance_question(answer)
177 }
178 }
179 KeyCode::Enter => {
180 if self.is_other_selected() {
181 self.mode = InputMode::OtherInput;
182 self.other_input.clear();
183 ViewAction::None
184 } else {
185 let question = self.current_question();
186 let option = &question.options[self.selected];
187 let answer = UserInputAnswer {
188 id: question.id.clone(),
189 label: option.label.clone(),
190 value: option.label.clone(),
191 };
192 self.advance_question(answer)
193 }
194 }
195 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
196 tool_id: self.tool_id.clone(),
197 }),
198 _ => ViewAction::None,
199 }
200 }
201
202 fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
203 match key.code {
204 KeyCode::Esc => {
205 self.mode = InputMode::Selecting;
206 self.other_input.clear();
207 ViewAction::None
208 }
209 KeyCode::Enter => {
210 let question = self.current_question();
211 let answer = UserInputAnswer {
212 id: question.id.clone(),
213 label: "Other".to_string(),
214 value: self.other_input.trim().to_string(),
215 };
216 self.advance_question(answer)
217 }
218 KeyCode::Backspace => {
219 self.other_input.pop();
220 ViewAction::None
221 }
222 KeyCode::Char('h')
223 if key
224 .modifiers
225 .contains(crossterm::event::KeyModifiers::CONTROL) =>
226 {
227 self.other_input.pop();
228 ViewAction::None
229 }
230 KeyCode::Char(ch) => {
231 if !ch.is_control() {
232 self.other_input.push(ch);
233 }
234 ViewAction::None
235 }
236 _ => ViewAction::None,
237 }
238 }
239 }
240
241 impl ModalView for UserInputView {
242 fn kind(&self) -> ModalKind {
243 ModalKind::UserInput
244 }
245
246 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
247 self
248 }
249
250 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
251 match self.mode {
252 InputMode::Selecting => self.handle_selecting_key(key),
253 InputMode::OtherInput => self.handle_other_input_key(key),
254 }
255 }
256
257 fn render(&self, area: Rect, buf: &mut Buffer) {
258 let question = self.current_question();
259 let total = self.request.questions.len();
260 let header = format!(
261 " {} ({}/{}) ",
262 question.header,
263 self.question_index + 1,
264 total
265 );
266
267 let mut lines: Vec<Line> = Vec::new();
268 lines.push(Line::from(vec![Span::styled(
269 "Action required",
270 Style::default().fg(palette::DEEPSEEK_SKY).bold(),
271 )]));
272 lines.push(Line::from(vec![
273 Span::styled(
274 question.header.clone(),
275 Style::default().fg(palette::TEXT_PRIMARY).bold(),
276 ),
277 Span::styled(
278 format!(" Question {} of {}", self.question_index + 1, total),
279 Style::default().fg(palette::TEXT_MUTED),
280 ),
281 ]));
282 lines.push(Line::from(""));
283 lines.push(Line::from(vec![Span::styled(
284 question.question.clone(),
285 Style::default().fg(palette::TEXT_PRIMARY).bold(),
286 )]));
287 lines.push(Line::from(""));
288
289 for (idx, option) in question.options.iter().enumerate() {
290 let number = idx + 1;
291 push_option_lines(
292 &mut lines,
293 self.selected == idx,
294 number,
295 option.label.clone(),
296 option.description.clone(),
297 );
298 }
299
300 let other_index = question.options.len();
301 let other_number = other_index + 1;
302 push_option_lines(
303 &mut lines,
304 self.selected == other_index,
305 other_number,
306 "Other".to_string(),
307 "Type a custom response".to_string(),
308 );
309
310 if self.mode == InputMode::OtherInput {
311 lines.push(Line::from(""));
312 lines.push(Line::from(vec![
313 Span::styled(
314 "> Custom response:",
315 Style::default().fg(palette::TEXT_PRIMARY).bold(),
316 ),
317 Span::raw(" "),
318 Span::styled(
319 if self.other_input.is_empty() {
320 "(type your response)".to_string()
321 } else {
322 self.other_input.clone()
323 },
324 Style::default().fg(palette::DEEPSEEK_BLUE),
325 ),
326 ]));
327 }
328
329 lines.push(Line::from(""));
330 if self.mode == InputMode::OtherInput {
331 lines.push(Line::from(vec![
332 Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
333 Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
334 Span::raw(" "),
335 Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
336 Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
337 ]));
338 } else {
339 lines.push(Line::from(vec![
340 Span::styled("1-4", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
341 Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
342 Span::raw(" "),
343 Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
344 Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
345 Span::raw(" "),
346 Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
347 Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
348 Span::raw(" "),
349 Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
350 Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
351 ]));
352 }
353
354 let paragraph = Paragraph::new(lines)
355 .alignment(Alignment::Left)
356 .wrap(Wrap { trim: true })
357 .block(modal_block(&header));
358
359 let popup_area = centered_rect(82, 68, area);
360 render_modal_chrome(area, popup_area, buf);
361 paragraph.render(popup_area, buf);
362 }
363 }
364
365 fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
366 let popup_layout = Layout::default()
367 .direction(Direction::Vertical)
368 .constraints([
369 Constraint::Percentage((100 - percent_y) / 2),
370 Constraint::Percentage(percent_y),
371 Constraint::Percentage((100 - percent_y) / 2),
372 ])
373 .split(r);
374 let horizontal = Layout::default()
375 .direction(Direction::Horizontal)
376 .constraints([
377 Constraint::Percentage((100 - percent_x) / 2),
378 Constraint::Percentage(percent_x),
379 Constraint::Percentage((100 - percent_x) / 2),
380 ])
381 .split(popup_layout[1]);
382 horizontal[1]
383 }
384
385 #[cfg(test)]
386 mod tests {
387 use super::*;
388 use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest};
389
390 fn render_view(view: &UserInputView, width: u16, height: u16) -> String {
391 let area = Rect::new(0, 0, width, height);
392 let mut buf = Buffer::empty(area);
393 view.render(area, &mut buf);
394
395 (0..height)
396 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
397 .collect::<Vec<_>>()
398 .join("\n")
399 }
400
401 fn sample_view() -> UserInputView {
402 UserInputView::new(
403 "tool-1",
404 UserInputRequest {
405 questions: vec![UserInputQuestion {
406 header: "Confirm".to_string(),
407 id: "confirm".to_string(),
408 question: "What should happen next?".to_string(),
409 options: vec![
410 UserInputOption {
411 label: "Ship it".to_string(),
412 description: "Proceed with the current change set".to_string(),
413 },
414 UserInputOption {
415 label: "Revise it".to_string(),
416 description: "Return to editing before continuing".to_string(),
417 },
418 ],
419 }],
420 },
421 )
422 }
423
424 #[test]
425 fn user_input_modal_calls_out_required_action_and_controls() {
426 let rendered = render_view(&sample_view(), 110, 36);
427
428 assert!(rendered.contains("Action required"));
429 assert!(rendered.contains("Question 1 of 1"));
430 assert!(rendered.contains("1-4"));
431 assert!(rendered.contains("quick pick"));
432 }
433
434 #[test]
435 fn user_input_modal_renders_custom_response_state() {
436 let mut view = sample_view();
437 view.selected = 2;
438 view.mode = InputMode::OtherInput;
439 view.other_input = "Need one more pass".to_string();
440
441 let rendered = render_view(&view, 110, 36);
442
443 assert!(rendered.contains("Custom response"));
444 assert!(rendered.contains("Need one more pass"));
445 assert!(rendered.contains("Enter"));
446 assert!(rendered.contains("submit"));
447 }
448 }
449
449 lines RUST