Improve Plan mode approval UX and release v0.3.20

This commit is contained in:
Hunter Bown
2026-02-17 17:10:47 -06:00
parent d2b64b260e
commit 6fedee021e
10 changed files with 295 additions and 47 deletions
+1 -1
View File
@@ -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();
+6
View File
@@ -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:
+1
View File
@@ -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;
+153
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -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,