From 5c50f75a8a23c4837cad9135b602463c914cd441 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Mon, 17 Aug 2026 12:52:56 +0200 Subject: [PATCH] Add emoji indicators for each research phase on the progress spinner Tag each phase (research, web search, page fetch, review, rejection, report writing) with a distinct emoji so the spinner line shows what's happening at a glance. Search/fetch tool calls now update the active spinner's message directly via a small shared handle, since they run as plain tool functions without one threaded down to them otherwise. Co-Authored-By: Claude Sonnet 5 --- deep_research/src/core.rs | 10 ++++++--- deep_research/src/progress.rs | 40 +++++++++++++++++++++++++++++++++-- deep_research/src/review.rs | 4 ++-- deep_research/src/tools.rs | 5 +++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/deep_research/src/core.rs b/deep_research/src/core.rs index 3711c6d..a96da55 100644 --- a/deep_research/src/core.rs +++ b/deep_research/src/core.rs @@ -1,4 +1,4 @@ -use crate::progress::Spinner; +use crate::progress::{REJECTED_EMOJI, REPORT_EMOJI, RESEARCH_EMOJI, Spinner}; use crate::review::{self, Review}; use crate::stream::write_text_stream; use crate::tools::{FetchPage, SearchWeb}; @@ -73,6 +73,10 @@ pub(crate) async fn research(topic: &str, show_progress: bool) -> anyhow::Result break; } + if show_progress { + eprintln!("{REJECTED_EMOJI} Findings rejected — revising for round {}...", round + 1); + } + feedback = Some(review); } @@ -131,7 +135,7 @@ async fn gather_findings( ), }; - let spinner = Spinner::start(show_progress, "Researching..."); + let spinner = Spinner::start(show_progress, format!("{RESEARCH_EMOJI} Researching...")); let findings = researcher .runner(task) .max_turns(MAX_RESEARCH_TURNS) @@ -169,7 +173,7 @@ async fn write_report( // Drop the spinner before streaming starts: report text is about to print // to the same terminal line, so the two must not race over stdout. - let spinner = Spinner::start(show_progress, "Writing report..."); + let spinner = Spinner::start(show_progress, format!("{REPORT_EMOJI} Writing report...")); let response_stream = writer .stream_prompt(format!("Topic: {topic}\n\nResearch notes:\n{findings}")) .await; diff --git a/deep_research/src/progress.rs b/deep_research/src/progress.rs index c81a2ff..7534f71 100644 --- a/deep_research/src/progress.rs +++ b/deep_research/src/progress.rs @@ -1,5 +1,28 @@ +use std::sync::{Mutex, OnceLock}; use std::time::Duration; +/// Emoji shown on the spinner line for whichever activity is currently +/// running, so the different phases (and the tool calls within the +/// research phase) stay visually distinct at a glance. +pub(crate) const RESEARCH_EMOJI: &str = "🔎"; +pub(crate) const SEARCH_EMOJI: &str = "🌐"; +pub(crate) const FETCH_EMOJI: &str = "📄"; +pub(crate) const REVIEW_EMOJI: &str = "🧐"; +pub(crate) const REJECTED_EMOJI: &str = "❌"; +pub(crate) const REPORT_EMOJI: &str = "✍️"; + +/// The spinner currently on screen, if any — set by `Spinner::start` and +/// cleared on drop. Tool implementations don't otherwise have a handle to +/// the active spinner (they're plain functions invoked by the model, not +/// passed one down through the tool-calling loop), so `set_activity` lets +/// them reach it here instead to reflect what they're doing — e.g. which +/// page they're fetching — on the same line. +static ACTIVE: OnceLock>> = OnceLock::new(); + +fn active() -> &'static Mutex> { + ACTIVE.get_or_init(|| Mutex::new(None)) +} + /// A terminal spinner for a research phase, shown only when logging is off — /// with logging on, the trace output already tells the user something is /// happening, and interleaving both would just be noisy. Clearing on drop @@ -7,7 +30,7 @@ use std::time::Duration; pub(crate) struct Spinner(Option); impl Spinner { - pub(crate) fn start(enabled: bool, message: &'static str) -> Self { + pub(crate) fn start(enabled: bool, message: impl Into) -> Self { if !enabled { return Self(None); } @@ -18,7 +41,9 @@ impl Spinner { indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") .expect("static template is valid"), ); - bar.set_message(message); + bar.set_message(message.into()); + + *active().lock().expect("spinner mutex poisoned") = Some(bar.clone()); Self(Some(bar)) } @@ -28,6 +53,17 @@ impl Drop for Spinner { fn drop(&mut self) { if let Some(bar) = &self.0 { bar.finish_and_clear(); + *active().lock().expect("spinner mutex poisoned") = None; } } } + +/// Updates the message of whatever spinner is currently running, if any. +/// A no-op when progress display is off (no spinner was ever started, so +/// `active()` stays empty) or between phases (the previous `Spinner` has +/// already dropped and cleared it). +pub(crate) fn set_activity(message: impl Into) { + if let Some(bar) = active().lock().expect("spinner mutex poisoned").as_ref() { + bar.set_message(message.into()); + } +} diff --git a/deep_research/src/review.rs b/deep_research/src/review.rs index 54211b2..0c6bb2c 100644 --- a/deep_research/src/review.rs +++ b/deep_research/src/review.rs @@ -3,7 +3,7 @@ use rig::providers::ollama; use rig::schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::progress::Spinner; +use crate::progress::{REVIEW_EMOJI, Spinner}; /// Judging whether a conclusion actually follows from its cited sources is a /// bounded, single-shot classification task, not multi-step reasoning — so @@ -50,7 +50,7 @@ pub(crate) async fn review_findings( .retries(2) .build(); - let spinner = Spinner::start(show_progress, "Reviewing findings..."); + let spinner = Spinner::start(show_progress, format!("{REVIEW_EMOJI} Reviewing findings...")); let review = reviewer .extract(format!("Topic: {topic}\n\nResearch findings to review:\n{findings}")) .await?; diff --git a/deep_research/src/tools.rs b/deep_research/src/tools.rs index 0a18396..85c5f91 100644 --- a/deep_research/src/tools.rs +++ b/deep_research/src/tools.rs @@ -1,3 +1,4 @@ +use crate::progress::{self, FETCH_EMOJI, SEARCH_EMOJI}; use rig::tool::ToolExecutionError; use scraper::{Html, Selector}; @@ -12,6 +13,8 @@ pub(crate) async fn search_web( /// The search query query: String, ) -> Result { + progress::set_activity(format!("{SEARCH_EMOJI} Searching: {query}")); + let response = reqwest::Client::new() .get("https://html.duckduckgo.com/html/") .query(&[("q", query.as_str())]) @@ -42,6 +45,8 @@ pub(crate) async fn fetch_page( /// The URL to fetch url: String, ) -> Result { + progress::set_activity(format!("{FETCH_EMOJI} Fetching: {url}")); + let response = reqwest::Client::new() .get(&url) .header("User-Agent", "Mozilla/5.0 (research-agent)")