diff --git a/rust/crates/caos-cli/TUI.md b/rust/crates/caos-cli/TUI.md index bfd6f29b..e99774f2 100644 --- a/rust/crates/caos-cli/TUI.md +++ b/rust/crates/caos-cli/TUI.md @@ -235,7 +235,10 @@ moves. The checkout and index remain untouched. CAOS points `caos/` directly at the validated conversation head, pushes it, and uses the authenticated `gh` CLI to find or open its pull -request. The first publication carries the conversation history; later +request. The live status names each publication stage as it runs — fetching +the base tip, sending it to the CAOS server, the preparation turn (whose tool +activity shows through, like an ordinary turn), validating the prepared +workspace, pushing the branch, and finding or opening the pull request. The first publication carries the conversation history; later publications advance the branch by fast-forward. The selected PR base is an ancestor of the published head, and the final tree must contain no reserved `.caos` state. diff --git a/rust/crates/caos-cli/src/bin/tui/app.rs b/rust/crates/caos-cli/src/bin/tui/app.rs index df1dce90..89abe450 100644 --- a/rust/crates/caos-cli/src/bin/tui/app.rs +++ b/rust/crates/caos-cli/src/bin/tui/app.rs @@ -25,8 +25,8 @@ use ratatui_crossterm::crossterm::event::{ use super::args::Args; use super::workspace::{ commit_working_tree, fetch_published_conversation, fetch_remote_branch_tip, - load_conversation_workspace, local_default_branch_tip, prepare_publish_workspace, - publish_conversation_branch, publish_conversation_pr, remote_base_is_ancestor, + find_or_open_conversation_pr, load_conversation_workspace, local_default_branch_tip, + prepare_publish_workspace, publish_conversation_branch, remote_base_is_ancestor, remote_default_branch, PublishedConversationSource, }; @@ -1371,7 +1371,7 @@ impl ConversationState { } fn sidebar_text(&self, max_cells: u16) -> (String, String) { - let detail = if self.running { + let detail = if self.running || self.publishing { self.running_activity() .map(|activity| { format!("{} {}", activity.running_verb(), activity.running_summary()) @@ -1379,7 +1379,7 @@ impl ConversationState { .unwrap_or_else(|| self.status.clone()) } else if self.generating_title { "Generating title…".to_string() - } else if self.reference_loading || self.publishing { + } else if self.reference_loading { self.status.clone() } else if let Some(attention) = &self.sidebar_attention { attention.clone() @@ -3890,12 +3890,19 @@ impl App { }; let conversation = self.selected().id.clone(); self.selected_mut().publishing = true; - self.selected_mut().status = "publishing the complete conversation branch".to_string(); + self.selected_mut().status = "validating the conversation tip".to_string(); let repo_dir = self.repo_dir.clone(); let tx = self.tx.clone(); std::thread::spawn(move || { + let status = |text: String| { + let _ = tx.send(UiMessage::Turn { + conversation: conversation.clone(), + event: TurnEvent::Status(text), + }); + }; let result = (|| { let prepared = prepare_publish_workspace(&diff.head, &diff.base_commit, &repo_dir)?; + status(format!("pushing branch caos/{conversation}")); publish_conversation_branch(&conversation, &prepared, &repo_dir) })(); let _ = tx.send(UiMessage::BranchPublished { @@ -3951,7 +3958,7 @@ impl App { let name = self.selected().id.clone(); let title = self.selected().title.clone(); self.selected_mut().publishing = true; - self.selected_mut().status = "fetching the selected PR base".to_string(); + self.selected_mut().status = format!("fetching the tip of {pr_base}"); let tx = self.tx.clone(); let head = self .selected() @@ -3963,14 +3970,25 @@ impl App { let options = self.selected().turn_options.clone(); let repo_dir = self.repo_dir.clone(); std::thread::spawn(move || { + let status = |text: String| { + let _ = tx.send(UiMessage::Turn { + conversation: name.clone(), + event: TurnEvent::Status(text), + }); + }; let result = (|| { let base_commit = fetch_remote_branch_tip(&pr_base, &repo_dir)?; let target = base_commit; let base_is_ancestor = remote_base_is_ancestor(&target, &head, &repo_dir)?; let transport = GitTransport::discover(&repo_dir)?; if !base_is_ancestor { + status(format!( + "sending base {} to the caos server", + short_hash(&target) + )); transport.ensure_pushed(&target)?; } + status("starting the publication preparation turn".to_string()); let message = publish_turn_message(&target, base_is_ancestor); let outcome = run_chat_turn( &transport, @@ -3986,13 +4004,24 @@ impl App { }); }, )?; + status("validating the prepared workspace".to_string()); let conversation = prepare_publish_workspace(&outcome.commit, &target, &repo_dir)?; let _ = tx.send(UiMessage::Completed { conversation: name.clone(), outcome, }); - publish_conversation_pr(&name, &title, &conversation, &pr_base, &repo_dir) + status(format!("pushing branch caos/{name}")); + let branch = publish_conversation_branch(&name, &conversation, &repo_dir)?; + status("finding or opening the pull request".to_string()); + find_or_open_conversation_pr( + &name, + &title, + &branch, + &conversation, + &pr_base, + &repo_dir, + ) })(); let _ = tx.send(UiMessage::Published { conversation: name, @@ -6425,6 +6454,92 @@ mod tests { assert!(!rendered.contains("Status")); } + fn rendered_screen(app: &App) -> String { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(app, frame)).unwrap(); + terminal + .backend() + .buffer() + .content + .chunks(100) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n") + } + + #[test] + fn publish_stage_statuses_replace_the_generic_publishing_summary() { + let mut conversation = state("talk-1"); + conversation.publishing = true; + conversation.status = "fetching the tip of main".to_string(); + let (mut app, tx) = app_with(vec![conversation]); + + let rendered = rendered_screen(&app); + assert!(rendered.contains("Publishing…")); + assert!(rendered.contains("fetching the tip of main")); + + tx.send(UiMessage::Turn { + conversation: "talk-1".to_string(), + event: TurnEvent::Status("pushing branch caos/talk-1".to_string()), + }) + .unwrap(); + assert!(app.drain_messages()); + assert_eq!(app.selected().status, "pushing branch caos/talk-1"); + let rendered = rendered_screen(&app); + assert!(rendered.contains("Publishing…")); + assert!(rendered.contains("pushing branch caos/talk-1")); + } + + #[test] + fn publishing_shows_a_running_tool_instead_of_the_generic_verb() { + let mut conversation = state("talk-1"); + conversation.publishing = true; + conversation.status = "starting the publication preparation turn".to_string(); + let (mut app, tx) = app_with(vec![conversation]); + + tx.send(UiMessage::Turn { + conversation: "talk-1".to_string(), + event: TurnEvent::ToolCall { + step_commit: "b".repeat(40), + request: "c".repeat(40), + round: 1, + tool_use_id: "call-1".to_string(), + name: "bash".to_string(), + summary: "$ cargo test".to_string(), + }, + }) + .unwrap(); + assert!(app.drain_messages()); + + let rendered = rendered_screen(&app); + assert!(rendered.contains("Running…")); + assert!(rendered.contains("$ cargo test")); + assert!(!rendered.contains("Publishing…")); + let (_, detail) = app.selected().sidebar_text(60); + assert_eq!(detail, "Running $ cargo test"); + + tx.send(UiMessage::Turn { + conversation: "talk-1".to_string(), + event: TurnEvent::ToolResult { + step_commit: "b".repeat(40), + request: "c".repeat(40), + round: 1, + tool_use_id: "call-1".to_string(), + is_error: false, + content: "ok".to_string(), + }, + }) + .unwrap(); + assert!(app.drain_messages()); + + let rendered = rendered_screen(&app); + assert!(rendered.contains("Publishing…")); + assert!(rendered.contains("starting the publication preparation turn")); + let (_, detail) = app.selected().sidebar_text(60); + assert_eq!(detail, "starting the publication preparation turn"); + } + #[test] fn active_turn_accepts_an_interjection() { let (repo, remote, _) = repo_with_default_branch("active-interjection", "main"); diff --git a/rust/crates/caos-cli/src/bin/tui/ui.rs b/rust/crates/caos-cli/src/bin/tui/ui.rs index c4c8de8f..8da85c32 100644 --- a/rust/crates/caos-cli/src/bin/tui/ui.rs +++ b/rust/crates/caos-cli/src/bin/tui/ui.rs @@ -537,10 +537,12 @@ fn render_live_activity( frame: &mut Frame<'_>, area: Rect, ) { - let (verb, summary) = if state.publishing { - ("Publishing", state.status.as_str()) - } else if let Some(activity) = state.running_activity() { + // A publish runs a real agent turn, so a tool in flight names the work + // more precisely than the generic publishing verb. + let (verb, summary) = if let Some(activity) = state.running_activity() { (activity.running_verb(), activity.running_summary()) + } else if state.publishing { + ("Publishing", state.status.as_str()) } else { ( match state.turn_phase { diff --git a/rust/crates/caos-cli/src/bin/tui/workspace.rs b/rust/crates/caos-cli/src/bin/tui/workspace.rs index 1deb9954..6806260f 100644 --- a/rust/crates/caos-cli/src/bin/tui/workspace.rs +++ b/rust/crates/caos-cli/src/bin/tui/workspace.rs @@ -151,16 +151,18 @@ pub(crate) fn prepare_publish_workspace( }) } -/// Publish the validated conversation history without checking it out. -pub(crate) fn publish_conversation_pr( +/// Find the pushed branch's open PR against `pr_base` (refreshing its title), +/// or open one. Split from the branch push so the publish flow can report +/// each stage separately. +pub(crate) fn find_or_open_conversation_pr( name: &str, title: &str, + branch: &str, conversation: &PreparedPublishConversation, pr_base: &str, cwd: &Path, ) -> Result { let title = conversation_pr_title(title); - let branch = publish_conversation_branch(name, conversation, cwd)?; let existing_url = capture_required( "gh", @@ -168,7 +170,7 @@ pub(crate) fn publish_conversation_pr( "pr", "list", "--head", - &branch, + branch, "--base", pr_base, "--state", @@ -191,8 +193,7 @@ pub(crate) fn publish_conversation_pr( capture_required( "gh", &[ - "pr", "create", "--head", &branch, "--base", pr_base, "--title", &title, "--body", - &body, + "pr", "create", "--head", branch, "--base", pr_base, "--title", &title, "--body", &body, ], cwd, )