diff --git a/Cargo.lock b/Cargo.lock index 4816bf47..8ef01a3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -726,7 +726,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.19" +version = "0.3.20" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 6bf9446b..fc62edad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deepseek-tui" -version = "0.3.19" +version = "0.3.20" edition = "2024" description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting" license = "MIT" diff --git a/src/core/engine.rs b/src/core/engine.rs index f41e4afd..6a60fac9 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -620,7 +620,7 @@ fn tool_result_metadata_summary(metadata: Option<&serde_json::Value>) -> Option< None } -fn compact_tool_result_for_context(tool_name: &str, output: &ToolResult) -> String { +pub(crate) fn compact_tool_result_for_context(tool_name: &str, output: &ToolResult) -> String { let raw = output.content.trim(); if raw.is_empty() { return String::new(); diff --git a/src/prompts/plan.txt b/src/prompts/plan.txt index 4529a6a1..47f1414b 100644 --- a/src/prompts/plan.txt +++ b/src/prompts/plan.txt @@ -8,6 +8,12 @@ In this mode, focus on: 3. Identifying potential issues, regressions, and edge cases upfront. 4. Creating a detailed plan using update_plan before implementation. +Interaction workflow: +1. Before publishing a plan, ask clarifying questions with request_user_input when requirements are ambiguous. +2. Use concise multiple-choice questions with numbered options and clear tradeoffs. +3. Keep it to 1-3 questions total, then synthesize the answers into update_plan output. +4. After emitting update_plan, stop and wait for explicit user approval before implementation. + Available tools: PLANNING: diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3a2117f3..f6b6c2ac 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -13,6 +13,7 @@ pub mod markdown_render; pub mod onboarding; pub mod pager; pub mod paste_burst; +pub mod plan_prompt; pub mod scrolling; pub mod selection; pub mod session_picker; diff --git a/src/tui/plan_prompt.rs b/src/tui/plan_prompt.rs new file mode 100644 index 00000000..e7434a13 --- /dev/null +++ b/src/tui/plan_prompt.rs @@ -0,0 +1,153 @@ +//! Modal prompt for selecting what to do after a plan is generated. + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use crate::palette; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +const PLAN_OPTIONS: [(&str, &str); 4] = [ + ( + "Accept plan (Agent)", + "Start implementation in Agent mode with approvals", + ), + ( + "Accept plan (YOLO)", + "Start implementation in YOLO mode (auto-approve)", + ), + ("Revise plan", "Ask follow-ups or request plan changes"), + ( + "Exit Plan mode", + "Return to Agent mode without implementation", + ), +]; + +#[derive(Debug, Clone, Default)] +pub struct PlanPromptView { + selected: usize, +} + +impl PlanPromptView { + pub fn new() -> Self { + Self::default() + } + + fn max_index(&self) -> usize { + PLAN_OPTIONS.len().saturating_sub(1) + } + + fn submit_selected(&self) -> ViewAction { + ViewAction::EmitAndClose(ViewEvent::PlanPromptSelected { + option: self.selected + 1, + }) + } + + fn submit_number(number: u32) -> ViewAction { + if (1..=u32::try_from(PLAN_OPTIONS.len()).unwrap_or(0)).contains(&number) { + ViewAction::EmitAndClose(ViewEvent::PlanPromptSelected { + option: usize::try_from(number).unwrap_or(1), + }) + } else { + ViewAction::None + } + } +} + +impl ModalView for PlanPromptView { + fn kind(&self) -> ModalKind { + ModalKind::PlanPrompt + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.selected = (self.selected + 1).min(self.max_index()); + ViewAction::None + } + KeyCode::Char(ch) if ch.is_ascii_digit() => { + let number = ch.to_digit(10).unwrap_or(0); + Self::submit_number(number) + } + KeyCode::Enter => self.submit_selected(), + KeyCode::Esc => ViewAction::Close, + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![Span::styled( + "Plan ready. Confirm next step:", + Style::default().fg(palette::TEXT_PRIMARY).bold(), + )])); + lines.push(Line::from("")); + + for (idx, (label, description)) in PLAN_OPTIONS.iter().enumerate() { + let selected = self.selected == idx; + let prefix = if selected { ">" } else { " " }; + let number = idx + 1; + let style = if selected { + Style::default().fg(palette::DEEPSEEK_SKY).bold() + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + lines.push(Line::from(vec![ + Span::raw(format!("{prefix} {number}) ")), + Span::styled((*label).to_string(), style), + Span::raw(" — "), + Span::styled( + (*description).to_string(), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "1-4=quick pick, Up/Down=select, Enter=confirm, Esc=close", + Style::default().fg(palette::TEXT_MUTED), + ))); + + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .title(Line::from(vec![Span::styled( + " Plan Confirmation ", + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::DEEPSEEK_SKY)), + ); + + let popup_area = centered_rect(66, 42, area); + paragraph.render(popup_area, buf); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 1764d087..c7cd3008 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -56,6 +56,7 @@ use crate::tui::event_broker::EventBroker; use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::paste_burst::CharDecision; +use crate::tui::plan_prompt::PlanPromptView; use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; use crate::tui::selection::TranscriptSelectionPoint; use crate::tui::session_picker::SessionPickerView; @@ -447,7 +448,11 @@ async fn run_event_loop( app.plan_tool_used_in_turn = true; } let tool_content = match &result { - Ok(output) => sanitize_stream_chunk(&output.content), + Ok(output) => sanitize_stream_chunk( + &crate::core::engine::compact_tool_result_for_context( + &name, output, + ), + ), Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), }; app.api_messages.push(Message { @@ -524,6 +529,9 @@ async fn run_event_loop( app.add_message(HistoryCell::System { content: plan_next_step_prompt(), }); + if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) { + app.view_stack.push(PlanPromptView::new()); + } } app.plan_tool_used_in_turn = false; @@ -1733,86 +1741,85 @@ async fn submit_or_steer_message( #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PlanChoice { - ImplementAgent, - ImplementYolo, + AcceptAgent, + AcceptYolo, RevisePlan, ExitPlan, } fn plan_next_step_prompt() -> String { [ - "Plan ready. Choose next step:", - " 1) Implement in Agent mode (approvals on)", - " 2) Implement in YOLO mode (auto-approve)", + "Plan ready. Review and choose:", + " 1) Accept + implement in Agent mode", + " 2) Accept + implement in YOLO mode", " 3) Revise the plan / ask follow-ups", " 4) Exit Plan mode", "", - "Type 1-4 and press Enter.", + "Use the plan confirmation popup or type 1-4 and press Enter.", ] .join("\n") } +fn plan_choice_from_option(option: usize) -> Option { + match option { + 1 => Some(PlanChoice::AcceptAgent), + 2 => Some(PlanChoice::AcceptYolo), + 3 => Some(PlanChoice::RevisePlan), + 4 => Some(PlanChoice::ExitPlan), + _ => None, + } +} + fn parse_plan_choice(input: &str) -> Option { let trimmed = input.trim().to_lowercase(); let first = trimmed.chars().next()?; - match first { - '1' => return Some(PlanChoice::ImplementAgent), - '2' => return Some(PlanChoice::ImplementYolo), - '3' => return Some(PlanChoice::RevisePlan), - '4' => return Some(PlanChoice::ExitPlan), - _ => {} + if let Some(digit) = first.to_digit(10) + && let Some(choice) = plan_choice_from_option(usize::try_from(digit).unwrap_or(0)) + { + return Some(choice); } match trimmed.as_str() { - "agent" | "a" => Some(PlanChoice::ImplementAgent), - "yolo" | "y" => Some(PlanChoice::ImplementYolo), + "accept" | "approve" | "agent" | "a" => Some(PlanChoice::AcceptAgent), + "accept-yolo" | "yolo" | "y" => Some(PlanChoice::AcceptYolo), "revise" | "edit" | "plan" | "stay" => Some(PlanChoice::RevisePlan), "normal" | "exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan), _ => None, } } -async fn handle_plan_choice( +async fn apply_plan_choice( app: &mut App, engine_handle: &EngineHandle, - input: &str, -) -> Result { - if !app.plan_prompt_pending { - return Ok(false); - } - - let choice = parse_plan_choice(input); - app.plan_prompt_pending = false; - - let Some(choice) = choice else { - return Ok(false); - }; - + choice: PlanChoice, +) -> Result<()> { match choice { - PlanChoice::ImplementAgent => { + PlanChoice::AcceptAgent => { app.set_mode(AppMode::Agent); app.add_message(HistoryCell::System { - content: "Plan approved. Switching to Agent mode and starting implementation." + content: "Plan accepted. Switching to Agent mode and starting implementation." .to_string(), }); - let followup = QueuedMessage::new("Proceed with the plan.".to_string(), None); + let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); if app.is_loading { app.queue_message(followup); - app.status_message = Some("Queued plan execution (agent mode).".to_string()); + app.status_message = + Some("Queued accepted plan execution (agent mode).".to_string()); } else { dispatch_user_message(app, engine_handle, followup).await?; } } - PlanChoice::ImplementYolo => { + PlanChoice::AcceptYolo => { app.set_mode(AppMode::Yolo); app.add_message(HistoryCell::System { - content: "Plan approved. Switching to YOLO mode and starting implementation." + content: "Plan accepted. Switching to YOLO mode and starting implementation." .to_string(), }); - let followup = QueuedMessage::new("Proceed with the plan.".to_string(), None); + let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); if app.is_loading { app.queue_message(followup); - app.status_message = Some("Queued plan execution (YOLO mode).".to_string()); + app.status_message = + Some("Queued accepted plan execution (YOLO mode).".to_string()); } else { dispatch_user_message(app, engine_handle, followup).await?; } @@ -1831,6 +1838,26 @@ async fn handle_plan_choice( } } + Ok(()) +} + +async fn handle_plan_choice( + app: &mut App, + engine_handle: &EngineHandle, + input: &str, +) -> Result { + if !app.plan_prompt_pending { + return Ok(false); + } + + let choice = parse_plan_choice(input); + app.plan_prompt_pending = false; + + let Some(choice) = choice else { + return Ok(false); + }; + + apply_plan_choice(app, engine_handle, choice).await?; Ok(true) } @@ -2378,6 +2405,16 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: content: "User input cancelled".to_string(), }); } + ViewEvent::PlanPromptSelected { option } => { + if app.plan_prompt_pending { + app.plan_prompt_pending = false; + if let Some(choice) = plan_choice_from_option(option) + && let Err(err) = apply_plan_choice(app, engine_handle, choice).await + { + app.status_message = Some(format!("Failed to apply plan selection: {err}")); + } + } + } ViewEvent::SessionSelected { session_id } => { let manager = match SessionManager::default_location() { Ok(manager) => manager, diff --git a/src/tui/ui/tests.rs b/src/tui/ui/tests.rs index 84fb4581..3425ae19 100644 --- a/src/tui/ui/tests.rs +++ b/src/tui/ui/tests.rs @@ -58,21 +58,35 @@ fn selection_point_from_position_ignores_top_padding() { #[test] fn parse_plan_choice_accepts_numbers() { - assert_eq!(parse_plan_choice("1"), Some(PlanChoice::ImplementAgent)); - assert_eq!(parse_plan_choice("2"), Some(PlanChoice::ImplementYolo)); + assert_eq!(parse_plan_choice("1"), Some(PlanChoice::AcceptAgent)); + assert_eq!(parse_plan_choice("2"), Some(PlanChoice::AcceptYolo)); assert_eq!(parse_plan_choice("3"), Some(PlanChoice::RevisePlan)); assert_eq!(parse_plan_choice("4"), Some(PlanChoice::ExitPlan)); } #[test] fn parse_plan_choice_accepts_aliases() { - assert_eq!(parse_plan_choice("agent"), Some(PlanChoice::ImplementAgent)); - assert_eq!(parse_plan_choice("yolo"), Some(PlanChoice::ImplementYolo)); + assert_eq!(parse_plan_choice("accept"), Some(PlanChoice::AcceptAgent)); + assert_eq!(parse_plan_choice("agent"), Some(PlanChoice::AcceptAgent)); + assert_eq!( + parse_plan_choice("accept-yolo"), + Some(PlanChoice::AcceptYolo) + ); + assert_eq!(parse_plan_choice("yolo"), Some(PlanChoice::AcceptYolo)); assert_eq!(parse_plan_choice("revise"), Some(PlanChoice::RevisePlan)); assert_eq!(parse_plan_choice("exit"), Some(PlanChoice::ExitPlan)); assert_eq!(parse_plan_choice("unknown"), None); } +#[test] +fn plan_choice_from_option_maps_expected_values() { + assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent)); + assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo)); + assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan)); + assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan)); + assert_eq!(plan_choice_from_option(5), None); +} + #[test] fn transcript_scroll_percent_is_clamped_and_relative() { assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0)); diff --git a/src/tui/user_input.rs b/src/tui/user_input.rs index 617cf99d..b059dcb5 100644 --- a/src/tui/user_input.rs +++ b/src/tui/user_input.rs @@ -81,6 +81,33 @@ impl UserInputView { self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1)); ViewAction::None } + KeyCode::Char(ch) if ch.is_ascii_digit() => { + let Some(number) = ch.to_digit(10) else { + return ViewAction::None; + }; + if number == 0 { + return ViewAction::None; + } + let index = usize::try_from(number - 1).unwrap_or(usize::MAX); + if index >= self.option_count() { + return ViewAction::None; + } + self.selected = index; + if self.is_other_selected() { + self.mode = InputMode::OtherInput; + self.other_input.clear(); + ViewAction::None + } else { + let question = self.current_question(); + let option = &question.options[self.selected]; + let answer = UserInputAnswer { + id: question.id.clone(), + label: option.label.clone(), + value: option.label.clone(), + }; + self.advance_question(answer) + } + } KeyCode::Enter => { if self.is_other_selected() { self.mode = InputMode::OtherInput; @@ -167,13 +194,14 @@ impl ModalView for UserInputView { for (idx, option) in question.options.iter().enumerate() { let selected = self.selected == idx; let prefix = if selected { ">" } else { " " }; + let number = idx + 1; let style = if selected { Style::default().fg(palette::DEEPSEEK_SKY).bold() } else { Style::default().fg(palette::TEXT_PRIMARY) }; lines.push(Line::from(vec![ - Span::raw(format!("{prefix} ")), + Span::raw(format!("{prefix} {number}) ")), Span::styled(option.label.clone(), style), Span::raw(" - "), Span::styled( @@ -190,8 +218,13 @@ impl ModalView for UserInputView { } else { Style::default().fg(palette::TEXT_PRIMARY) }; + let other_number = other_index + 1; lines.push(Line::from(vec![ - Span::raw(if other_selected { "> " } else { " " }), + Span::raw(if other_selected { + format!("> {other_number}) ") + } else { + format!(" {other_number}) ") + }), Span::styled("Other", other_style), Span::raw(" - "), Span::styled( @@ -220,7 +253,7 @@ impl ModalView for UserInputView { let hint = if self.mode == InputMode::OtherInput { "Enter=submit, Esc=back" } else { - "Up/Down=select, Enter=confirm, Esc=cancel" + "Number keys=quick pick, Up/Down=select, Enter=confirm, Esc=cancel" }; lines.push(Line::from(Span::styled( hint, diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 3f4b8c93..8af43be6 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -12,6 +12,7 @@ pub enum ModalKind { Approval, Elevation, UserInput, + PlanPrompt, CommandPalette, Help, SubAgents, @@ -46,6 +47,9 @@ pub enum ViewEvent { UserInputCancelled { tool_id: String, }, + PlanPromptSelected { + option: usize, + }, SubAgentsRefresh, SessionSelected { session_id: String,