feat(auto-drive): show coordinator summary while CLI runs

This commit is contained in:
James Peter
2025-09-30 09:49:17 +10:00
parent 54718dc5a6
commit 5e273dcb74
5 changed files with 76 additions and 42 deletions
+2 -2
View File
@@ -1614,9 +1614,9 @@ impl App<'_> {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::AutoCoordinatorDecision { status, thoughts, prompt } => {
AppEvent::AutoCoordinatorDecision { status, summary, prompt } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_decision(status, thoughts, prompt);
widget.auto_handle_decision(status, summary, prompt);
}
}
AppEvent::AutoCoordinatorThinking { delta, summary_index } => {
+1 -1
View File
@@ -148,7 +148,7 @@ pub(crate) enum AppEvent {
AutoCoordinatorDecision {
status: AutoCoordinatorStatus,
thoughts: String,
summary: String,
prompt: Option<String>,
},
AutoCoordinatorThinking {
+47 -26
View File
@@ -456,7 +456,7 @@ pub(crate) struct GhostState {
struct AutoCoordinatorUiState {
active: bool,
goal: Option<String>,
current_thoughts: Option<String>,
current_summary: Option<String>,
current_prompt: Option<String>,
current_display_line: Option<String>,
placeholder_phrase: Option<String>,
@@ -469,7 +469,8 @@ struct AutoCoordinatorUiState {
countdown_id: u64,
seconds_remaining: u8,
awaiting_goal_input: bool,
last_broadcast_thought: Option<String>,
last_broadcast_summary: Option<String>,
last_decision_summary: Option<String>,
last_decision_display: Option<String>,
observer_telemetry: Option<AutoObserverTelemetry>,
observer_status: AutoObserverStatus,
@@ -9805,13 +9806,13 @@ impl ChatWidget<'_> {
self.auto_state.reset();
self.auto_state.active = true;
self.auto_state.goal = Some(goal_text.clone());
self.auto_state.current_thoughts = None;
self.auto_state.current_summary = None;
self.auto_state.current_display_line = 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_thought = None;
self.auto_state.last_broadcast_summary = None;
self.auto_state.seconds_remaining = AUTO_COUNTDOWN_SECONDS;
self.auto_state.waiting_for_response = true;
self.auto_state.coordinator_waiting = true;
@@ -9842,8 +9843,8 @@ impl ChatWidget<'_> {
} else {
self.auto_state.waiting_for_response = true;
self.auto_state.coordinator_waiting = true;
self.auto_state.current_thoughts = None;
self.auto_state.last_broadcast_thought = None;
self.auto_state.current_summary = None;
self.auto_state.last_broadcast_summary = None;
self.auto_state.current_summary_index = None;
self.auto_state.placeholder_phrase =
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
@@ -9857,15 +9858,17 @@ impl ChatWidget<'_> {
pub(crate) fn auto_handle_decision(
&mut self,
status: AutoCoordinatorStatus,
thoughts: String,
summary: String,
prompt: Option<String>,
) {
if !self.auto_state.active {
return;
}
let summary = summary.trim().to_string();
self.auto_state.last_decision_summary = Some(summary.clone());
self.auto_state.coordinator_waiting = false;
self.auto_on_reasoning_final(&thoughts);
self.auto_on_reasoning_final(&summary);
self.auto_state.last_decision_display = self.auto_state.current_display_line.clone();
self.auto_state.paused_for_manual_edit = false;
self.auto_state.resume_after_manual_submit = false;
@@ -9889,21 +9892,21 @@ impl ChatWidget<'_> {
self.auto_start_countdown(countdown_id);
}
AutoCoordinatorStatus::Success => {
let lower = thoughts.to_ascii_lowercase();
let lower = summary.to_ascii_lowercase();
let message = if lower.starts_with("coordinator success:") {
thoughts
summary
} else {
format!("Coordinator success: {thoughts}")
format!("Coordinator success: {summary}")
};
self.auto_stop(Some(message));
return;
}
AutoCoordinatorStatus::Failed => {
let lower = thoughts.to_ascii_lowercase();
let lower = summary.to_ascii_lowercase();
let message = if lower.starts_with("coordinator error:") {
thoughts
summary
} else {
format!("Coordinator error: {thoughts}")
format!("Coordinator error: {summary}")
};
self.auto_stop(Some(message));
return;
@@ -10032,8 +10035,8 @@ impl ChatWidget<'_> {
self.auto_state.resume_after_manual_submit = false;
self.auto_state.seconds_remaining = 0;
let post_submit_display = self.auto_state.last_decision_display.clone();
self.auto_state.current_thoughts = None;
self.auto_state.last_broadcast_thought = None;
self.auto_state.current_summary = None;
self.auto_state.last_broadcast_summary = None;
self.auto_state.current_display_line = post_submit_display.clone();
self.auto_state.current_summary_index = None;
self.auto_state.placeholder_phrase = post_submit_display.is_none().then(|| {
@@ -10114,7 +10117,7 @@ impl ChatWidget<'_> {
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.current_thoughts = Some(String::new());
self.auto_state.current_summary = Some(String::new());
self.auto_state.current_summary_index = None;
self.auto_state.placeholder_phrase = None;
self.auto_state.thinking_prefix_stripped = false;
@@ -10155,6 +10158,24 @@ impl ChatWidget<'_> {
};
let mut status_lines = vec![append_thought_ellipsis(&status_text)];
if self.auto_state.waiting_for_response && !self.auto_state.coordinator_waiting {
if let Some(summary) = self.auto_state.last_decision_summary.as_ref() {
let trimmed = summary.trim();
if !trimmed.is_empty() {
let collapsed = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
if !collapsed.is_empty() {
let current_line = status_lines
.first()
.map(|line| line.trim_end_matches('…').trim())
.unwrap_or("");
if collapsed != current_line {
let display = Self::truncate_with_ellipsis(&collapsed, 160);
status_lines.push(display);
}
}
}
}
}
if let Some(telemetry) = self.auto_state.observer_telemetry.as_ref() {
let status_label = match self.auto_state.observer_status {
AutoObserverStatus::Ok => "ok",
@@ -10255,11 +10276,11 @@ impl ChatWidget<'_> {
return;
}
let Some(thoughts) = self.auto_state.current_thoughts.as_ref() else {
let Some(summary) = self.auto_state.current_summary.as_ref() else {
return;
};
if let Some(title) = extract_latest_bold_title(thoughts) {
if let Some(title) = extract_latest_bold_title(summary) {
let needs_update = self
.auto_state
.current_display_line
@@ -10274,7 +10295,7 @@ impl ChatWidget<'_> {
}
}
fn auto_broadcast_thoughts(&mut self, raw: &str) {
fn auto_broadcast_summary(&mut self, raw: &str) {
if !self.auto_state.active {
return;
}
@@ -10292,7 +10313,7 @@ impl ChatWidget<'_> {
if self
.auto_state
.last_broadcast_thought
.last_broadcast_summary
.as_ref()
.map(|prev| prev == &display_text)
.unwrap_or(false)
@@ -10300,7 +10321,7 @@ impl ChatWidget<'_> {
return;
}
self.auto_state.last_broadcast_thought = Some(display_text);
self.auto_state.last_broadcast_summary = Some(display_text);
}
fn auto_on_reasoning_delta(&mut self, delta: &str, summary_index: Option<u32>) {
@@ -10311,7 +10332,7 @@ impl ChatWidget<'_> {
if let Some(idx) = summary_index {
if self.auto_state.current_summary_index != Some(idx) {
self.auto_state.current_summary_index = Some(idx);
self.auto_state.current_thoughts = Some(String::new());
self.auto_state.current_summary = Some(String::new());
self.auto_state.thinking_prefix_stripped = false;
}
}
@@ -10333,7 +10354,7 @@ impl ChatWidget<'_> {
{
let entry = self
.auto_state
.current_thoughts
.current_summary
.get_or_insert_with(String::new);
if auto_drive_strings::is_auto_drive_phrase(entry) {
@@ -10349,11 +10370,11 @@ impl ChatWidget<'_> {
return;
}
self.auto_state.current_thoughts = Some(text.to_string());
self.auto_state.current_summary = Some(text.to_string());
self.auto_state.thinking_prefix_stripped = true;
self.auto_state.current_summary_index = None;
self.auto_update_display_title();
self.auto_broadcast_thoughts(text);
self.auto_broadcast_summary(text);
if self.auto_state.waiting_for_response {
self.auto_rebuild_live_ring();
@@ -84,7 +84,7 @@ pub(super) enum AutoCoordinatorCommand {
#[derive(Debug, Deserialize)]
struct CoordinatorDecision {
finish_status: String,
thoughts: String,
summary: String,
#[serde(default)]
prompt: Option<String>,
}
@@ -227,11 +227,11 @@ fn run_auto_loop(
&app_event_tx,
&cancel_token,
) {
Ok((status, thoughts, prompt_opt)) => {
Ok((status, summary, prompt_opt)) => {
let prompt_clone = prompt_opt.clone();
let event = AppEvent::AutoCoordinatorDecision {
status,
thoughts,
summary,
prompt: prompt_opt,
};
app_event_tx.send(event);
@@ -267,7 +267,7 @@ fn run_auto_loop(
}
let event = AppEvent::AutoCoordinatorDecision {
status: AutoCoordinatorStatus::Failed,
thoughts: format!("Coordinator error: {err}"),
summary: format!("Coordinator error: {err}"),
prompt: None,
};
app_event_tx.send(event);
@@ -347,7 +347,7 @@ fn should_trigger_observer(requests_completed: u64, cadence: u64) -> bool {
fn build_developer_message(goal_text: &str, environment_details: &str) -> (String, String) {
let intro = format!(
"You are coordinating prompts sent to a running Code CLI process. You should act like a human maintainer of the project would act. You will see a **Primary Goal** below - this is what you are always working towards.\n\n**Rules**\n- `finish_status`: one of `continue`, `finish_success`, or `finish_failed`.\n * Use `continue` when another prompt is reasonable. Always prefer this option.\n * Use `finish_success` when the goal has been completed in it's entirety and absolutely no work remains.\n * Use `finish_failed` when the goal absolutely can not be satisfied or you are stuck in a loop. This should almost never be used. Try other approaches and gather more information if there is no clear path forward.\n- `thoughts`: short status (<= 160 characters) describing you thought process around what the next prompt will do\n- `prompt`: the exact prompt to provide to the Code CLI process. You will receive the response the CLI provides.\n- First plan, then execute. Allow the CLI to plan for you. You should get it to do the thinking for you.\n- Keep the prompt minimal to give the CLI room to make independent decision.\n- Don't repeat yourself. You will see past prompts and outputs showing current progress. Always push the project forward.\n- Often a simple 'Please continue' or 'Work on feature A next' or 'What do you think is the best approach?' is sufficient. Your job is to keep things running in an appropriate direction. The CLI does all the actual work and thinking. You do not need to know much about the project or codebase, allow the CLI to do all this for you. You are focused on overall direction not implementation details.\n- Only stop when no other options remain. A human is observing your work and will step in if they want to go in a different direction. You should not ask them for assistance - you should use your judgement to move on the most likely path forward. The human may override your message send to the CLI if they choose to go in another direction. This allows you to just guess the best path, knowing an overseer will step in if needed.\n\nUseful commands:\n`/review <what to review>` e.g. `/review latest commit` - this spins up a specialist review thread for the CLI which excels at identify issues. This is useful for repeatedly reviewing code changes you make and fixing them.\n`/reasoning <high|medium|low>` e.g. set `/reasoning high` if the CLI makes a poor decision or `/reasoning low` to move faster on simple tasks\n\nEnvironment:\\n{environment_details}"
"You are coordinating prompts sent to a running Code CLI process. You should act like a human maintainer of the project would act. You will see a **Primary Goal** below - this is what you are always working towards.\n\n**Rules**\n- `finish_status`: one of `continue`, `finish_success`, or `finish_failed`.\n * Use `continue` when another prompt is reasonable. Always prefer this option.\n * Use `finish_success` when the goal has been completed in it's entirety and absolutely no work remains.\n * Use `finish_failed` when the goal absolutely can not be satisfied or you are stuck in a loop. This should almost never be used. Try other approaches and gather more information if there is no clear path forward.\n- `summary`: short summary (<= 160 characters) describing what will happen when the CLI performs the next prompt\n- `prompt`: the exact prompt to provide to the Code CLI process. You will receive the response the CLI provides.\n- First plan, then execute. Allow the CLI to plan for you. You should get it to do the thinking for you.\n- Keep the prompt minimal to give the CLI room to make independent decision.\n- Don't repeat yourself. You will see past prompts and outputs showing current progress. Always push the project forward.\n- Often a simple 'Please continue' or 'Work on feature A next' or 'What do you think is the best approach?' is sufficient. Your job is to keep things running in an appropriate direction. The CLI does all the actual work and thinking. You do not need to know much about the project or codebase, allow the CLI to do all this for you. You are focused on overall direction not implementation details.\n- Only stop when no other options remain. A human is observing your work and will step in if they want to go in a different direction. You should not ask them for assistance - you should use your judgement to move on the most likely path forward. The human may override your message send to the CLI if they choose to go in another direction. This allows you to just guess the best path, knowing an overseer will step in if needed.\n\nUseful commands:\n`/review <what to review>` e.g. `/review latest commit` - this spins up a specialist review thread for the CLI which excels at identify issues. This is useful for repeatedly reviewing code changes you make and fixing them.\n`/reasoning <high|medium|low>` e.g. set `/reasoning high` if the CLI makes a poor decision or `/reasoning low` to move faster on simple tasks\n\nEnvironment:\\n{environment_details}"
);
let primary_goal = format!("**Primary Goal**\n{goal_text}");
(intro, primary_goal)
@@ -392,14 +392,19 @@ fn build_schema() -> Value {
"enum": ["continue", "finish_success", "finish_failed"],
"description": "Decision on how to proceed"
},
"thoughts": { "type": "string", "minLength": 1, "maxLength": 160 },
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "Short summary describing what will happen when the CLI performs the next prompt"
},
"prompt": {
"type": ["string", "null"],
"minLength": 1,
"description": "Prompt to send to Code CLI when finish_status is 'continue'"
}
},
"required": ["finish_status", "thoughts", "prompt"],
"required": ["finish_status", "summary", "prompt"],
"additionalProperties": false
})
}
@@ -450,7 +455,7 @@ fn request_coordinator_decision(
_ => None,
};
Ok((status, decision.thoughts.trim().to_string(), prompt_opt))
Ok((status, decision.summary.trim().to_string(), prompt_opt))
}
fn request_decision(
+13 -5
View File
@@ -1008,25 +1008,25 @@ fn auto_thinking_keeps_previous_display_until_decision() {
chat.auto_state.active = true;
chat.auto_state.waiting_for_response = true;
chat.auto_state.coordinator_waiting = true;
chat.auto_state.current_display_line = Some("Prev thought".to_string());
chat.auto_state.current_thoughts = Some("Prev thought".to_string());
chat.auto_state.current_display_line = Some("Prev summary".to_string());
chat.auto_state.current_summary = Some("Prev summary".to_string());
chat.auto_handle_thinking("**Next:** run lint".to_string(), Some(3));
assert_eq!(chat.auto_state.current_summary_index, Some(3));
assert_eq!(
chat.auto_state.current_display_line.as_deref(),
Some("Prev thought")
Some("Prev summary")
);
chat.auto_handle_thinking("extra detail".to_string(), None);
assert_eq!(
chat.auto_state.current_display_line.as_deref(),
Some("Prev thought")
Some("Prev summary")
);
}
#[test]
fn auto_decision_persists_thought_through_cli_cycle() {
fn auto_decision_persists_summary_through_cli_cycle() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.auto_state.active = true;
chat.auto_state.waiting_for_response = true;
@@ -1041,11 +1041,19 @@ fn auto_decision_persists_thought_through_cli_cycle() {
assert_eq!(chat.auto_state.current_display_line.as_deref(), Some("Plan:"));
assert!(!chat.auto_state.coordinator_waiting, "spinner should stop");
assert!(!chat.auto_state.waiting_for_response, "coordinator finished");
assert_eq!(
chat.auto_state.last_decision_summary.as_deref(),
Some("**Plan:** run tests")
);
chat.auto_submit_prompt();
assert!(chat.auto_state.waiting_for_response, "waiting on CLI");
assert!(!chat.auto_state.coordinator_waiting, "spinner stays off during CLI");
assert_eq!(chat.auto_state.current_display_line.as_deref(), Some("Plan:"));
assert_eq!(
chat.auto_state.last_decision_summary.as_deref(),
Some("**Plan:** run tests")
);
chat.auto_state.placeholder_phrase = None;
let (tx, _rx_handle) = channel();