Improve Plan mode approval UX and release v0.3.20
This commit is contained in:
Generated
+1
-1
@@ -726,7 +726,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.19"
|
||||
version = "0.3.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Line> = 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]
|
||||
}
|
||||
+74
-37
@@ -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<PlanChoice> {
|
||||
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<PlanChoice> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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,
|
||||
|
||||
+18
-4
@@ -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));
|
||||
|
||||
+36
-3
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user