feat(tui/auto): add setup toggles for Auto Drive

This commit is contained in:
James Peter
2025-10-04 23:26:29 +10:00
parent c323a434f7
commit 603bbdaebe
6 changed files with 708 additions and 155 deletions
+25
View File
@@ -1672,6 +1672,31 @@ impl App<'_> {
);
}
}
AppEvent::AutoSetupToggleReview => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_setup_toggle_review();
}
}
AppEvent::AutoSetupToggleSubagents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_setup_toggle_subagents();
}
}
AppEvent::AutoSetupSelectCountdown(mode) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_setup_select_countdown(mode);
}
}
AppEvent::AutoSetupConfirm => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_setup_confirm();
}
}
AppEvent::AutoSetupCancel => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_setup_cancel();
}
}
AppEvent::PerformUndoRestore {
commit,
restore_files,
+57
View File
@@ -104,6 +104,58 @@ pub(crate) struct AutoObserverTelemetry {
pub last_intervention: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AutoContinueMode {
Immediate,
TenSeconds,
SixtySeconds,
Manual,
}
impl Default for AutoContinueMode {
fn default() -> Self {
Self::TenSeconds
}
}
impl AutoContinueMode {
pub fn seconds(self) -> Option<u8> {
match self {
Self::Immediate => Some(0),
Self::TenSeconds => Some(10),
Self::SixtySeconds => Some(60),
Self::Manual => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Immediate => "Immediate",
Self::TenSeconds => "10 seconds",
Self::SixtySeconds => "60 seconds",
Self::Manual => "Manual approval",
}
}
pub fn cycle_forward(self) -> Self {
match self {
Self::Immediate => Self::TenSeconds,
Self::TenSeconds => Self::SixtySeconds,
Self::SixtySeconds => Self::Manual,
Self::Manual => Self::Immediate,
}
}
pub fn cycle_backward(self) -> Self {
match self {
Self::Immediate => Self::Manual,
Self::TenSeconds => Self::Immediate,
Self::SixtySeconds => Self::TenSeconds,
Self::Manual => Self::SixtySeconds,
}
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -174,6 +226,11 @@ pub(crate) enum AppEvent {
replace_message: Option<String>,
additional_instructions: Option<String>,
},
AutoSetupToggleReview,
AutoSetupToggleSubagents,
AutoSetupSelectCountdown(AutoContinueMode),
AutoSetupConfirm,
AutoSetupCancel,
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally. Includes the full command text.
@@ -1,9 +1,10 @@
use crate::app_event::AppEvent;
use crate::app_event::{AppEvent, AutoContinueMode};
use crate::app_event_sender::AppEventSender;
use crate::auto_drive_strings;
use crate::colors;
use crate::header_wave::{HeaderBorderWeaveEffect, HeaderWaveEffect};
use crate::spinner;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
@@ -15,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use super::{
bottom_pane_view::{BottomPaneView, ConditionalUpdate},
chat_composer::ChatComposer,
BottomPane,
};
#[derive(Clone, Debug)]
@@ -29,7 +31,7 @@ pub(crate) struct AutoCoordinatorButton {
}
#[derive(Clone, Debug)]
pub(crate) struct AutoCoordinatorViewModel {
pub(crate) struct AutoActiveViewModel {
#[allow(dead_code)]
pub goal: Option<String>,
pub status_lines: Vec<String>,
@@ -43,6 +45,20 @@ pub(crate) struct AutoCoordinatorViewModel {
pub cli_running: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct AutoSetupViewModel {
pub goal: String,
pub auto_review_enabled: bool,
pub subagents_enabled: bool,
pub continue_mode: AutoContinueMode,
}
#[derive(Clone, Debug)]
pub(crate) enum AutoCoordinatorViewModel {
Setup(AutoSetupViewModel),
Active(AutoActiveViewModel),
}
struct ButtonContext {
label: String,
enabled: bool,
@@ -88,19 +104,15 @@ impl AutoCoordinatorView {
self.model = model;
}
fn build_context(&self) -> VariantContext {
let button = self
.model
.button
.as_ref()
.map(|btn| ButtonContext {
label: btn.label.clone(),
enabled: btn.enabled,
});
fn build_context(model: &AutoActiveViewModel) -> VariantContext {
let button = model.button.as_ref().map(|btn| ButtonContext {
label: btn.label.clone(),
enabled: btn.enabled,
});
VariantContext {
button,
ctrl_hint: self.model.ctrl_switch_hint.clone(),
manual_hint: self.model.manual_hint.clone(),
ctrl_hint: model.ctrl_switch_hint.clone(),
manual_hint: model.manual_hint.clone(),
}
}
@@ -134,8 +146,8 @@ impl AutoCoordinatorView {
}
}
fn spinner_should_run(&self) -> bool {
self.model.cli_running
fn spinner_should_run(model: &AutoActiveViewModel) -> bool {
model.cli_running
}
fn overlay_text(&self, spinner_symbol: &str) -> Option<String> {
@@ -265,11 +277,11 @@ impl AutoCoordinatorView {
}
}
fn derived_status_entries(&self) -> Vec<(String, Style)> {
fn derived_status_entries(&self, model: &AutoActiveViewModel) -> Vec<(String, Style)> {
let mut entries: Vec<(String, Style)> = Vec::new();
if self.model.awaiting_submission {
let text = if let Some(countdown) = &self.model.countdown {
if model.awaiting_submission {
let text = if let Some(countdown) = &model.countdown {
format!("Auto continue in {}s", countdown.remaining)
} else {
"Awaiting confirmation".to_string()
@@ -277,7 +289,7 @@ impl AutoCoordinatorView {
entries.push((text, Style::default().fg(colors::text_dim())));
}
for status in &self.model.status_lines {
for status in &model.status_lines {
let trimmed = status.trim();
if trimmed.is_empty() {
continue;
@@ -313,8 +325,8 @@ impl AutoCoordinatorView {
lines
}
fn cli_prompt_lines(&self, style: Style) -> Option<Vec<Line<'static>>> {
self.model.cli_prompt.as_ref().map(|prompt| {
fn cli_prompt_lines(&self, model: &AutoActiveViewModel, style: Style) -> Option<Vec<Line<'static>>> {
model.cli_prompt.as_ref().map(|prompt| {
prompt
.lines()
.map(|line| Line::from(Span::styled(line.trim_end().to_string(), style)))
@@ -436,7 +448,12 @@ impl AutoCoordinatorView {
.sum()
}
fn estimated_height(&self, width: u16, ctx: &VariantContext) -> u16 {
fn estimated_height_active(
&self,
width: u16,
ctx: &VariantContext,
model: &AutoActiveViewModel,
) -> u16 {
let inner_width = self.inner_width(width);
let button_height = if ctx.button.is_some() { 3 } else { 0 };
let hint_height = ctx
@@ -453,12 +470,10 @@ impl AutoCoordinatorView {
Self::wrap_count(ctrl_hint, inner_width)
};
let awaiting = self.model.awaiting_submission;
let mut total = 0;
if awaiting {
if let Some(prompt) = &self.model.cli_prompt {
if model.awaiting_submission {
if let Some(prompt) = &model.cli_prompt {
total += Self::wrap_count(prompt, inner_width).max(1);
}
if ctx.button.is_some() {
@@ -469,7 +484,7 @@ impl AutoCoordinatorView {
total += ctrl_height.max(1);
}
} else {
let status_entries = self.derived_status_entries();
let status_entries = self.derived_status_entries(model);
for (_, (text, _)) in status_entries.iter().enumerate() {
total += Self::wrap_count(text, inner_width);
}
@@ -490,16 +505,16 @@ impl AutoCoordinatorView {
.min(u16::MAX as usize) as u16
}
fn render_internal(&self, area: Rect, buf: &mut Buffer, ctx: &VariantContext) {
self.render_classic(area, buf, ctx);
}
fn render_classic(&self, area: Rect, buf: &mut Buffer, ctx: &VariantContext) {
fn render_active(
&self,
area: Rect,
buf: &mut Buffer,
model: &AutoActiveViewModel,
) {
let now = Instant::now();
let waiting = self.model.waiting_for_response;
let mut frame_needed = false;
let should_enable_border = waiting && !self.model.cli_running;
let should_enable_border = model.waiting_for_response && !model.cli_running;
let mut border_enabled = self.header_border.is_enabled();
if should_enable_border && !border_enabled {
self.header_border.set_enabled(true, now);
@@ -513,7 +528,7 @@ impl AutoCoordinatorView {
frame_needed = true;
}
let spinner_active = self.spinner_should_run();
let spinner_active = Self::spinner_should_run(model);
let spinner_def = spinner::current_spinner();
let spinner_symbol = if spinner_active {
let now_ms = SystemTime::now()
@@ -536,38 +551,40 @@ impl AutoCoordinatorView {
return;
}
let status_entries = self.derived_status_entries();
let status_entries = self.derived_status_entries(model);
let mut content_lines: Vec<Line<'static>> = Vec::new();
let mut footer_lines: Vec<Line<'static>> = Vec::new();
let mut has_button_block = false;
if self.model.awaiting_submission {
if let Some(prompt_lines) =
self.cli_prompt_lines(Style::default().fg(colors::text_dim()))
{
content_lines.extend(prompt_lines);
}
let ctx = Self::build_context(model);
if let Some(button_block) = self.button_block_lines(ctx) {
if model.awaiting_submission {
if let Some(prompt_lines) =
self.cli_prompt_lines(model, Style::default().fg(colors::text_dim()))
{
content_lines.extend(prompt_lines);
}
if let Some(button_block) = self.button_block_lines(&ctx) {
has_button_block = true;
footer_lines.extend(button_block);
} else if let Some(ctrl_hint_line) = self.ctrl_hint_line(ctx) {
} else if let Some(ctrl_hint_line) = self.ctrl_hint_line(&ctx) {
footer_lines.push(ctrl_hint_line);
}
} else {
let status_lines = self.status_lines_with_entries(&status_entries);
content_lines.extend(status_lines);
if let Some(button_block) = self.button_block_lines(ctx) {
if let Some(button_block) = self.button_block_lines(&ctx) {
has_button_block = true;
footer_lines.extend(button_block);
}
if let Some(hint_line) = self.manual_hint_line(ctx) {
if let Some(hint_line) = self.manual_hint_line(&ctx) {
footer_lines.push(hint_line);
}
if let Some(ctrl_hint_line) = self.ctrl_hint_line(ctx) {
if let Some(ctrl_hint_line) = self.ctrl_hint_line(&ctx) {
footer_lines.push(Line::default());
footer_lines.push(ctrl_hint_line);
}
@@ -640,6 +657,155 @@ impl AutoCoordinatorView {
}
}
fn estimated_height_setup(&self, width: u16, model: &AutoSetupViewModel) -> u16 {
let inner_width = self.inner_width(width);
if inner_width == 0 {
return 2;
}
let lines = Self::setup_text_lines(model);
let mut total = 0usize;
for line in lines {
if line.is_empty() {
total += 1;
} else {
total += Self::wrap_count(&line, inner_width).max(1);
}
}
total
.saturating_add(2)
.min(u16::MAX as usize) as u16
}
fn render_setup(&self, area: Rect, buf: &mut Buffer, model: &AutoSetupViewModel) {
let now = Instant::now();
if self.header_wave.is_enabled() {
self.header_wave.set_enabled(false, now);
}
if self.header_border.is_enabled() {
self.header_border.set_enabled(false, now);
}
let Some(inner) = self.render_frame(area, buf, now, None) else {
return;
};
let inner = self.apply_left_padding(inner, buf);
if inner.height == 0 {
return;
}
let lines = Self::setup_render_lines(model);
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.render(inner, buf);
}
fn setup_text_lines(model: &AutoSetupViewModel) -> Vec<String> {
let goal_text = model.goal.trim();
let goal_display = if goal_text.is_empty() {
"Goal: (none)".to_string()
} else {
format!("Goal: {goal_text}")
};
vec![
goal_display,
String::new(),
Self::setup_toggle_text("Automatic review", "R", model.auto_review_enabled),
Self::setup_toggle_text("Sub agents", "S", model.subagents_enabled),
Self::setup_countdown_text(model),
String::new(),
"Enter to launch Auto Drive | Esc to cancel".to_string(),
]
}
fn setup_toggle_text(label: &str, key_hint: &str, enabled: bool) -> String {
let checkbox = if enabled { "[x]" } else { "[ ]" };
format!("{checkbox} {label} ({key_hint} to toggle)")
}
fn setup_countdown_text(model: &AutoSetupViewModel) -> String {
format!(
"Auto-continue: {} (C cycle | 0=now | 1=10s | 6=60s | M=manual)",
model.continue_mode.label()
)
}
fn setup_render_lines(model: &AutoSetupViewModel) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let goal_text = model.goal.trim();
let goal_display = if goal_text.is_empty() {
"(none)"
} else {
goal_text
};
lines.push(Line::from(vec![
Span::styled("Goal: ", Style::default().fg(colors::text())),
Span::styled(
goal_display.to_string(),
Style::default().fg(colors::text()).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::default());
lines.push(Self::setup_toggle_line(
"Automatic review",
"R",
model.auto_review_enabled,
));
lines.push(Self::setup_toggle_line("Sub agents", "S", model.subagents_enabled));
lines.push(Self::setup_countdown_line(model));
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"Enter to launch Auto Drive | Esc to cancel".to_string(),
Style::default()
.fg(colors::text_dim())
.add_modifier(Modifier::BOLD),
)));
lines
}
fn setup_toggle_line(label: &str, key_hint: &str, enabled: bool) -> Line<'static> {
let checkbox = if enabled { "[x]" } else { "[ ]" };
let state_style = if enabled {
Style::default()
.fg(colors::success())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors::text_dim())
};
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(checkbox.to_string(), state_style));
spans.push(Span::raw(" "));
spans.push(Span::styled(label.to_string(), Style::default().fg(colors::text())));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("({key_hint} to toggle)"),
Style::default().fg(colors::text_dim()),
));
Line::from(spans)
}
fn setup_countdown_line(model: &AutoSetupViewModel) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(
"Auto-continue: ".to_string(),
Style::default().fg(colors::text()),
));
spans.push(Span::styled(
model.continue_mode.label().to_string(),
Style::default()
.fg(colors::primary())
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
"C cycle | 0=now | 1=10s | 6=60s | M=manual".to_string(),
Style::default().fg(colors::text_dim()),
));
Line::from(spans)
}
fn apply_left_padding(&self, area: Rect, buf: &mut Buffer) -> Rect {
if area.width <= 1 {
return area;
@@ -662,18 +828,95 @@ impl AutoCoordinatorView {
}
impl<'a> BottomPaneView<'a> for AutoCoordinatorView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
let AutoCoordinatorViewModel::Setup(model) = &self.model else {
return;
};
let has_ctrl_like = key_event.modifiers.contains(KeyModifiers::CONTROL)
|| key_event.modifiers.contains(KeyModifiers::ALT)
|| key_event.modifiers.contains(KeyModifiers::SUPER);
if has_ctrl_like {
return;
}
match key_event.code {
KeyCode::Enter => {
self.app_event_tx.send(AppEvent::AutoSetupConfirm);
}
KeyCode::Esc => {
self.app_event_tx.send(AppEvent::AutoSetupCancel);
}
KeyCode::Left => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(model.continue_mode.cycle_backward()));
}
KeyCode::Right => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(model.continue_mode.cycle_forward()));
}
KeyCode::Char(ch) => {
let lowered = ch.to_ascii_lowercase();
match lowered {
'r' => {
self.app_event_tx.send(AppEvent::AutoSetupToggleReview);
}
's' => {
self.app_event_tx.send(AppEvent::AutoSetupToggleSubagents);
}
'c' => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(model.continue_mode.cycle_forward()));
}
'0' => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(AutoContinueMode::Immediate));
}
'1' => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(AutoContinueMode::TenSeconds));
}
'6' => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(AutoContinueMode::SixtySeconds));
}
'm' => {
self
.app_event_tx
.send(AppEvent::AutoSetupSelectCountdown(AutoContinueMode::Manual));
}
_ => {}
}
}
_ => {}
}
}
fn desired_height(&self, width: u16) -> u16 {
let ctx = self.build_context();
self.estimated_height(width, &ctx)
match &self.model {
AutoCoordinatorViewModel::Active(model) => {
let ctx = Self::build_context(model);
self.estimated_height_active(width, &ctx, model)
}
AutoCoordinatorViewModel::Setup(model) => self.estimated_height_setup(width, model),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let ctx = self.build_context();
if area.height == 0 {
return;
}
self.render_internal(area, buf, &ctx);
match &self.model {
AutoCoordinatorViewModel::Active(model) => self.render_active(area, buf, model),
AutoCoordinatorViewModel::Setup(model) => self.render_setup(area, buf, model),
}
}
fn update_status_text(&mut self, text: String) -> ConditionalUpdate {
+2
View File
@@ -61,9 +61,11 @@ pub(crate) enum CancellationEvent {
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
pub(crate) use auto_coordinator_view::{
AutoActiveViewModel,
AutoCoordinatorButton,
AutoCoordinatorView,
AutoCoordinatorViewModel,
AutoSetupViewModel,
CountdownState,
};
pub(crate) use login_accounts_view::{
+321 -100
View File
@@ -128,8 +128,10 @@ use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TurnDiffEvent;
use crate::bottom_pane::{
AutoActiveViewModel,
AutoCoordinatorButton,
AutoCoordinatorViewModel,
AutoSetupViewModel,
CountdownState,
};
use crate::exec_command::strip_bash_lc_and_escape;
@@ -191,7 +193,6 @@ const STATUS_TOKENS_PREFIX: &str = " ";
const RESUME_PLACEHOLDER_MESSAGE: &str = "Resuming previous session...";
const RESUME_NO_HISTORY_NOTICE: &str =
"No saved messages for this session. Start typing to continue.";
const AUTO_COUNTDOWN_SECONDS: u8 = 10;
const ENABLE_WARP_STRIPES: bool = false;
fn status_field_prefix(label: &str) -> String {
@@ -226,6 +227,7 @@ use crate::app_event::{
AutoCoordinatorStatus,
AutoObserverStatus,
AutoObserverTelemetry,
AutoContinueMode,
BackgroundPlacement,
TerminalAfter,
TerminalCommandGate,
@@ -501,7 +503,6 @@ pub(crate) struct GhostState {
queued_user_messages: VecDeque<UserMessage>,
}
#[derive(Default)]
struct AutoCoordinatorUiState {
active: bool,
goal: Option<String>,
@@ -533,6 +534,10 @@ struct AutoCoordinatorUiState {
observer_telemetry: Option<AutoObserverTelemetry>,
observer_status: AutoObserverStatus,
coordinator_waiting: bool,
review_enabled: bool,
subagents_enabled: bool,
continue_mode: AutoContinueMode,
setup: Option<AutoSetupState>,
}
impl AutoCoordinatorUiState {
@@ -541,8 +546,72 @@ impl AutoCoordinatorUiState {
}
fn countdown_active(&self) -> bool {
self.awaiting_submission && !self.paused_for_manual_edit
self.awaiting_submission
&& !self.paused_for_manual_edit
&& self
.countdown_seconds()
.map(|seconds| seconds > 0)
.unwrap_or(false)
}
fn countdown_seconds(&self) -> Option<u8> {
self.continue_mode.seconds()
}
fn reset_countdown(&mut self) {
self.seconds_remaining = self.countdown_seconds().unwrap_or(0);
}
}
impl Default for AutoCoordinatorUiState {
fn default() -> Self {
let continue_mode = AutoContinueMode::default();
let seconds = continue_mode.seconds().unwrap_or(0);
Self {
active: false,
goal: None,
current_summary: None,
current_progress_past: None,
current_progress_current: None,
current_cli_prompt: None,
current_cli_context: None,
current_display_line: None,
current_display_is_summary: false,
current_reasoning_title: None,
placeholder_phrase: None,
thinking_prefix_stripped: false,
current_summary_index: None,
awaiting_submission: false,
waiting_for_response: false,
paused_for_manual_edit: false,
resume_after_manual_submit: false,
waiting_for_review: false,
countdown_id: 0,
seconds_remaining: seconds,
awaiting_goal_input: false,
last_broadcast_summary: None,
last_decision_summary: None,
last_decision_progress_past: None,
last_decision_progress_current: None,
last_decision_display: None,
last_decision_display_is_summary: false,
observer_telemetry: None,
observer_status: AutoObserverStatus::default(),
coordinator_waiting: false,
review_enabled: false,
subagents_enabled: false,
continue_mode,
setup: None,
}
}
}
#[derive(Clone)]
struct AutoSetupState {
goal: String,
auto_review_enabled: bool,
subagents_enabled: bool,
continue_mode: AutoContinueMode,
}
const AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS: u32 = 3;
@@ -8628,11 +8697,12 @@ impl ChatWidget<'_> {
self.auto_resolve_on_task_complete(last_agent_message.clone());
}
// Auto-review: if the coordinator marked this as a write turn, kick off a review pass
if self.auto_state.active {
if let Some(cfg) = self.pending_auto_turn_config.take() {
if !self.is_review_flow_active() {
self.auto_handle_post_turn_review(cfg);
}
if let Some(cfg) = self.pending_auto_turn_config.take() {
if self.auto_state.active
&& self.auto_state.review_enabled
&& !self.is_review_flow_active()
{
self.auto_handle_post_turn_review(cfg);
}
}
// Defensive: clear transient agents-preparing state
@@ -11472,6 +11542,100 @@ fi\n\
}
}
fn auto_present_setup(&mut self) {
let Some(setup) = self.auto_state.setup.as_ref() else {
return;
};
let goal = setup.goal.clone();
let auto_review_enabled = setup.auto_review_enabled;
let subagents_enabled = setup.subagents_enabled;
let continue_mode = setup.continue_mode;
self.auto_state.review_enabled = auto_review_enabled;
self.auto_state.subagents_enabled = subagents_enabled;
self.auto_state.continue_mode = continue_mode;
self.auto_state.reset_countdown();
let model = AutoCoordinatorViewModel::Setup(AutoSetupViewModel {
goal,
auto_review_enabled,
subagents_enabled,
continue_mode,
});
self.bottom_pane.show_auto_coordinator_view(model);
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
self.bottom_pane
.update_status_text("Auto Drive Setup".to_string());
self.bottom_pane
.set_standard_terminal_hint(Some("Enter to launch | Esc to cancel".to_string()));
self.bottom_pane.ensure_input_focus();
self.update_header_border_activation();
self.request_redraw();
}
fn auto_launch_with_setup(&mut self, setup: AutoSetupState) {
let AutoSetupState {
goal,
auto_review_enabled,
subagents_enabled,
continue_mode,
} = setup;
let conversation = self.rebuild_auto_history();
match start_auto_coordinator(
self.app_event_tx.clone(),
goal.clone(),
conversation,
self.config.clone(),
self.config.debug,
self.config.auto_drive_observer_cadence,
) {
Ok(handle) => {
self.auto_handle = Some(handle);
self.auto_state.reset();
self.auto_state.setup = None;
self.auto_state.review_enabled = auto_review_enabled;
self.auto_state.subagents_enabled = subagents_enabled;
self.auto_state.continue_mode = continue_mode;
self.auto_state.reset_countdown();
self.auto_state.active = true;
self.auto_state.goal = Some(goal.clone());
self.auto_state.current_summary = None;
self.auto_state.current_progress_past = None;
self.auto_state.current_progress_current = None;
self.auto_state.current_cli_prompt = None;
self.auto_state.current_cli_context = None;
self.auto_state.current_display_line = None;
self.auto_state.current_display_is_summary = false;
self.auto_state.current_reasoning_title = None;
self.auto_state.current_summary_index = None;
self.auto_state.placeholder_phrase =
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
self.auto_state.thinking_prefix_stripped = false;
self.auto_state.last_broadcast_summary = None;
self.auto_state.last_decision_progress_past = None;
self.auto_state.last_decision_progress_current = None;
self.auto_state.waiting_for_response = true;
self.auto_state.coordinator_waiting = true;
self.bottom_pane
.set_standard_terminal_hint(Some("Esc to stop Auto Drive".to_string()));
self.update_header_border_activation();
self.auto_rebuild_live_ring();
self.push_background_tail(format!("Auto Drive started: {goal}"));
self.request_redraw();
}
Err(err) => {
self.push_background_tail(format!("Coordinator failed to start: {err}"));
self.auto_state.setup = Some(AutoSetupState {
goal,
auto_review_enabled,
subagents_enabled,
continue_mode,
});
self.auto_present_setup();
}
}
}
pub(crate) fn handle_auto_command(&mut self, goal: Option<String>) {
let provided = goal.unwrap_or_default();
let trimmed = provided.trim();
@@ -11514,49 +11678,72 @@ fi\n\
self.auto_stop(None);
}
let conversation = self.rebuild_auto_history();
match start_auto_coordinator(
self.app_event_tx.clone(),
goal_text.clone(),
conversation,
self.config.clone(),
self.config.debug,
self.config.auto_drive_observer_cadence,
) {
Ok(handle) => {
self.auto_handle = Some(handle);
self.auto_state.reset();
self.auto_state.active = true;
self.auto_state.goal = Some(goal_text.clone());
self.auto_state.current_summary = None;
self.auto_state.current_progress_past = None;
self.auto_state.current_progress_current = None;
self.auto_state.current_cli_prompt = None;
self.auto_state.current_cli_context = None;
self.auto_state.current_display_line = None;
self.auto_state.current_display_is_summary = false;
self.auto_state.current_reasoning_title = None;
self.auto_state.current_summary_index = None;
self.auto_state.placeholder_phrase =
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
self.auto_state.thinking_prefix_stripped = false;
self.auto_state.last_broadcast_summary = None;
self.auto_state.last_decision_progress_past = None;
self.auto_state.last_decision_progress_current = None;
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
self.auto_state.waiting_for_response = true;
self.auto_state.coordinator_waiting = true;
self.update_header_border_activation();
self.auto_rebuild_live_ring();
self.push_background_tail(format!("Auto Drive started: {goal_text}"));
self.request_redraw();
}
Err(err) => {
self.push_background_tail(format!("Coordinator failed to start: {err}"));
self.auto_state.reset();
self.auto_state.setup = Some(AutoSetupState {
goal: goal_text.clone(),
auto_review_enabled: false,
subagents_enabled: false,
continue_mode: AutoContinueMode::default(),
});
self.auto_state.review_enabled = false;
self.auto_state.subagents_enabled = false;
self.auto_state.continue_mode = AutoContinueMode::default();
self.auto_state.reset_countdown();
self.push_background_tail(format!(
"Auto Drive setup ready. Review defaults before launching goal: {goal_text}"
));
self.auto_present_setup();
}
pub(crate) fn auto_setup_toggle_review(&mut self) {
if let Some(setup) = self.auto_state.setup.as_mut() {
setup.auto_review_enabled = !setup.auto_review_enabled;
self.auto_state.review_enabled = setup.auto_review_enabled;
self.auto_present_setup();
}
}
pub(crate) fn auto_setup_toggle_subagents(&mut self) {
if let Some(setup) = self.auto_state.setup.as_mut() {
setup.subagents_enabled = !setup.subagents_enabled;
self.auto_state.subagents_enabled = setup.subagents_enabled;
self.auto_present_setup();
}
}
pub(crate) fn auto_setup_select_countdown(&mut self, mode: AutoContinueMode) {
if let Some(setup) = self.auto_state.setup.as_mut() {
if setup.continue_mode != mode {
setup.continue_mode = mode;
self.auto_state.continue_mode = mode;
self.auto_state.reset_countdown();
self.auto_present_setup();
}
}
}
pub(crate) fn auto_setup_confirm(&mut self) {
if let Some(setup) = self.auto_state.setup.clone() {
self.auto_launch_with_setup(setup);
}
}
pub(crate) fn auto_setup_cancel(&mut self) {
if self.auto_state.setup.is_none() {
return;
}
self.auto_state.reset();
self.bottom_pane.clear_auto_coordinator_view();
self.bottom_pane.set_standard_terminal_hint(None);
self.bottom_pane.update_status_text(String::new());
self.bottom_pane.set_task_running(false);
self.bottom_pane.ensure_input_focus();
self.update_header_border_activation();
self.request_redraw();
self.push_background_tail("Auto Drive setup canceled.".to_string());
}
fn auto_send_conversation(&mut self) {
if !self.auto_state.active || self.auto_state.waiting_for_response {
return;
@@ -11644,12 +11831,13 @@ fi\n\
};
self.auto_state.current_cli_prompt = Some(prompt_text.clone());
self.auto_state.awaiting_submission = true;
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
self.auto_state.reset_countdown();
self.auto_state.countdown_id = self.auto_state.countdown_id.wrapping_add(1);
let countdown_id = self.auto_state.countdown_id;
let countdown_seconds = self.auto_state.countdown_seconds();
self.auto_rebuild_live_ring();
self.request_redraw();
self.auto_start_countdown(countdown_id);
self.auto_start_countdown(countdown_id, countdown_seconds);
}
AutoCoordinatorStatus::Success => {
let normalized = summary_text.trim();
@@ -11684,29 +11872,34 @@ fi\n\
}
}
fn auto_start_countdown(&self, countdown_id: u64) {
if AUTO_COUNTDOWN_SECONDS == 0 {
let _ = self.app_event_tx.send(AppEvent::AutoCoordinatorCountdown {
countdown_id,
seconds_left: 0,
});
return;
}
let tx = self.app_event_tx.clone();
std::thread::spawn(move || {
let mut remaining = AUTO_COUNTDOWN_SECONDS;
while remaining > 0 {
std::thread::sleep(std::time::Duration::from_secs(1));
remaining -= 1;
if !tx.send_with_result(AppEvent::AutoCoordinatorCountdown {
fn auto_start_countdown(&self, countdown_id: u64, countdown_seconds: Option<u8>) {
match countdown_seconds {
Some(0) => {
let _ = self.app_event_tx.send(AppEvent::AutoCoordinatorCountdown {
countdown_id,
seconds_left: remaining,
}) {
break;
}
seconds_left: 0,
});
}
});
Some(seconds) => {
let tx = self.app_event_tx.clone();
std::thread::spawn(move || {
let mut remaining = seconds;
while remaining > 0 {
std::thread::sleep(std::time::Duration::from_secs(1));
remaining -= 1;
if !tx.send_with_result(AppEvent::AutoCoordinatorCountdown {
countdown_id,
seconds_left: remaining,
}) {
break;
}
}
});
}
None => {
// Manual approval mode: do not start countdown automatically.
}
}
}
pub(crate) fn auto_handle_countdown(&mut self, countdown_id: u64, seconds_left: u8) {
@@ -11759,8 +11952,9 @@ fi\n\
self.auto_state.current_cli_prompt = Some(message.to_string());
if self.auto_state.awaiting_submission {
self.auto_state.countdown_id = self.auto_state.countdown_id.wrapping_add(1);
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
self.auto_start_countdown(self.auto_state.countdown_id);
self.auto_state.reset_countdown();
let countdown_seconds = self.auto_state.countdown_seconds();
self.auto_start_countdown(self.auto_state.countdown_id, countdown_seconds);
}
let summary = Self::truncate_with_ellipsis(message, 160);
self.push_background_tail(format!("Observer replaced prompt with: {summary}"));
@@ -11785,6 +11979,10 @@ fi\n\
}
fn auto_handle_post_turn_review(&mut self, cfg: TurnConfig) {
if !self.auto_state.review_enabled {
self.auto_turn_review_state = None;
return;
}
if cfg.read_only {
self.auto_turn_review_state = None;
return;
@@ -11842,7 +12040,7 @@ fi\n\
}
fn prepare_auto_turn_review_state(&mut self) {
if !self.auto_state.active {
if !self.auto_state.active || !self.auto_state.review_enabled {
self.auto_turn_review_state = None;
return;
}
@@ -11938,11 +12136,12 @@ fi\n\
self.auto_state.current_reasoning_title = None;
self.auto_state.thinking_prefix_stripped = false;
// If coordinator hinted medium/high complexity, prime the Agents HUD
let should_prepare_agents = self
.pending_auto_turn_config
.as_ref()
.and_then(|cfg| cfg.complexity)
.is_some_and(|c| matches!(c, TurnComplexity::Medium | TurnComplexity::High));
let should_prepare_agents = self.auto_state.subagents_enabled
&& self
.pending_auto_turn_config
.as_ref()
.and_then(|cfg| cfg.complexity)
.is_some_and(|c| matches!(c, TurnComplexity::Medium | TurnComplexity::High));
if should_prepare_agents {
self.prepare_agents();
}
@@ -11950,7 +12149,11 @@ fi\n\
// Build hidden preface with explicit agent_run guidance
let preface = self.build_auto_turn_preface(&prompt);
self.prepare_auto_turn_review_state();
if self.auto_state.review_enabled {
self.prepare_auto_turn_review_state();
} else {
self.auto_turn_review_state = None;
}
self.bottom_pane.update_status_text(String::new());
self.bottom_pane.set_task_running(false);
if preface.trim().is_empty() {
@@ -11994,7 +12197,7 @@ fi\n\
self.auto_state.paused_for_manual_edit = true;
self.auto_state.resume_after_manual_submit = true;
self.auto_state.countdown_id = self.auto_state.countdown_id.wrapping_add(1);
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
self.auto_state.reset_countdown();
self.clear_composer();
self.insert_str(&full_prompt);
self.bottom_pane.ensure_input_focus();
@@ -12042,20 +12245,30 @@ fi\n\
parts.push("This turn is likely low complexity, so you should focus on addressing it directly without using agents.".to_string());
}
TurnComplexity::Medium => {
parts.push(format!(
"This turn is likely medium complexity, so you should use at least 2 agents using `agent_run` and read_only: {}.",
ro_str
));
if self.auto_state.subagents_enabled {
parts.push(format!(
"This turn is likely medium complexity, so you should use at least 2 agents using `agent_run` and read_only: {}.",
ro_str
));
} else {
parts.push("This turn is likely medium complexity; break the work into clear steps and handle them directly without launching sub agents.".to_string());
}
}
TurnComplexity::High => {
parts.push(format!(
"This turn is likely high complexity, you should use `agent_run` with all models [\"claude\",\"gemini\",\"qwen\",\"code\",\"cloud\"] and read_only: {}.",
ro_str
));
if self.auto_state.subagents_enabled {
parts.push(format!(
"This turn is likely high complexity, you should use `agent_run` with all models [\"claude\",\"gemini\",\"qwen\",\"code\",\"cloud\"] and read_only: {}.",
ro_str
));
} else {
parts.push("This turn is likely high complexity; plan carefully and execute step by step without invoking sub agents.".to_string());
}
}
}
if matches!(complexity, TurnComplexity::Medium | TurnComplexity::High) {
if self.auto_state.subagents_enabled
&& matches!(complexity, TurnComplexity::Medium | TurnComplexity::High)
{
if cfg.read_only {
parts.push("Use `agent_wait` with `batch_id` and `return_all: true` to wait for all agents to complete automatically. Once they complete you should collate their output into your final output.".to_string());
} else {
@@ -12111,7 +12324,7 @@ fi\n\
self.auto_state.awaiting_submission = false;
self.auto_state.paused_for_manual_edit = false;
self.auto_state.resume_after_manual_submit = false;
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
self.auto_state.reset_countdown();
self.auto_state.current_summary = Some(String::new());
self.auto_state.current_progress_past = None;
self.auto_state.current_progress_current = None;
@@ -12140,6 +12353,9 @@ fi\n\
}
fn auto_start_post_turn_review(&mut self, scope: Option<AutoReviewCommitScope>) {
if !self.auto_state.review_enabled {
return;
}
let (prompt, hint, auto_metadata, review_metadata, preparation) = match scope {
Some(scope) => {
let commit_id = scope.commit;
@@ -12289,13 +12505,14 @@ fi\n\
.clone()
.filter(|p| !p.trim().is_empty());
let countdown = if self.auto_state.awaiting_submission && AUTO_COUNTDOWN_SECONDS > 0 {
Some(CountdownState {
remaining: self
.auto_state
.seconds_remaining
.min(AUTO_COUNTDOWN_SECONDS),
})
let countdown_limit = self.auto_state.countdown_seconds();
let countdown = if self.auto_state.awaiting_submission {
match countdown_limit {
Some(limit) if limit > 0 => Some(CountdownState {
remaining: self.auto_state.seconds_remaining.min(limit),
}),
_ => None,
}
} else {
None
};
@@ -12304,7 +12521,11 @@ fi\n\
let label = if self.auto_state.countdown_active() {
format!("Continue ({}s)", self.auto_state.seconds_remaining)
} else {
"Continue now".to_string()
match countdown_limit {
Some(0) => "Continue now".to_string(),
Some(_) => "Continue now".to_string(),
None => "Continue".to_string(),
}
};
Some(AutoCoordinatorButton {
label,
@@ -12326,7 +12547,7 @@ fi\n\
String::new()
};
let model = AutoCoordinatorViewModel {
let model = AutoCoordinatorViewModel::Active(AutoActiveViewModel {
goal: self.auto_state.goal.clone(),
status_lines,
cli_prompt,
@@ -12337,7 +12558,7 @@ fi\n\
manual_hint,
ctrl_switch_hint,
cli_running,
};
});
self
.bottom_pane
+9 -4
View File
@@ -1192,7 +1192,7 @@ fn observer_report_replaces_prompt_and_resets_countdown() {
chat.auto_state.active = true;
chat.auto_state.awaiting_submission = true;
chat.auto_state.current_prompt = Some("old prompt".to_string());
chat.auto_state.current_cli_prompt = Some("old prompt".to_string());
chat.auto_state.countdown_id = 7;
chat.auto_state.seconds_remaining = 3;
@@ -1209,7 +1209,7 @@ fn observer_report_replaces_prompt_and_resets_countdown() {
Some("double-check the failing command".to_string()),
);
assert_eq!(chat.auto_state.current_prompt.as_deref(), Some("new prompt"));
assert_eq!(chat.auto_state.current_cli_prompt.as_deref(), Some("new prompt"));
assert_eq!(chat.auto_state.observer_status, AutoObserverStatus::Failing);
assert_eq!(
chat
@@ -1221,7 +1221,8 @@ fn observer_report_replaces_prompt_and_resets_countdown() {
telemetry.trigger_count
);
assert_eq!(chat.auto_state.countdown_id, 8);
assert_eq!(chat.auto_state.seconds_remaining, AUTO_COUNTDOWN_SECONDS);
let default_seconds = chat.auto_state.countdown_seconds().unwrap_or(0);
assert_eq!(chat.auto_state.seconds_remaining, default_seconds);
let texts = cell_texts(&chat);
assert!(texts.iter().any(|line| line.contains("Observer guidance")));
@@ -2247,10 +2248,11 @@ fn pump_app_events(
fn prepare_auto_drive_for_manual_pause(chat: &mut ChatWidget<'_>) {
chat.auto_state.reset();
chat.auto_state.active = true;
chat.auto_state.review_enabled = true;
chat.auto_state.awaiting_submission = true;
chat.auto_state.current_cli_prompt = Some("echo hi".to_string());
chat.auto_state.goal = Some("demo goal".to_string());
chat.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
chat.auto_state.seconds_remaining = chat.auto_state.countdown_seconds().unwrap_or(0);
}
fn esc_key() -> KeyEvent {
@@ -2282,6 +2284,7 @@ fn auto_waits_for_review_before_continuing() {
chat.auto_state.waiting_for_response = true;
chat.auto_state.coordinator_waiting = true;
chat.auto_state.goal = Some("demo goal".to_string());
chat.auto_state.review_enabled = true;
let (tx, auto_rx) = channel();
chat.auto_handle = Some(AutoCoordinatorHandle::for_tests(tx));
@@ -2383,6 +2386,7 @@ fn auto_resume_clears_review_flag_even_when_waiting_for_response() {
chat.auto_state.waiting_for_response = true;
chat.auto_state.coordinator_waiting = true;
chat.auto_state.goal = Some("demo goal".to_string());
chat.auto_state.review_enabled = true;
chat.auto_resolve_state = Some(AutoResolveState::new(
"Review workspace".to_string(),
@@ -2409,6 +2413,7 @@ fn auto_blocks_until_auto_resolve_completes() {
chat.auto_state.waiting_for_response = true;
chat.auto_state.coordinator_waiting = true;
chat.auto_state.goal = Some("demo goal".to_string());
chat.auto_state.review_enabled = true;
chat.config.tui.review_auto_resolve = true;
let (tx, auto_rx) = channel();