返回 CodeWhale
footer_ui.rs
根目录 / crates / tui / src / tui / footer_ui.rs
1 use std::cell::RefCell;
2 use std::collections::HashMap;
3
4 use crate::tui::app::App;
5 use crate::tui::history::summarize_tool_output;
6 use crate::tui::output_rows_cache::hash_str;
7 use crate::tui::subagent_routing::{active_fanout_counts, running_agent_count};
8 use crate::tui::ui_text::truncate_line_to_width;
9
10 /// Seconds the current turn has gone without observable stream activity.
11 pub(crate) fn provider_wait_idle_secs(app: &App) -> u64 {
12 app.turn_last_activity_at
13 .or(app.turn_started_at)
14 .map(|at| at.elapsed().as_secs())
15 .unwrap_or(0)
16 }
17
18 /// Threshold after which a provider wait with a planned fanout is logged as
19 /// a structured incident (once per turn).
20 const PROVIDER_WAIT_INCIDENT_SECS: u64 = 120;
21
22 /// Log a compact structured incident when the parent turn has spent a long
23 /// time in provider wait while a sub-agent fanout plan is present (#3095).
24 pub(crate) fn maybe_log_provider_wait_incident(app: &mut App) {
25 if app.provider_wait_incident_logged || !app.is_loading {
26 return;
27 }
28 let elapsed = match app.turn_started_at {
29 Some(at) => at.elapsed().as_secs(),
30 None => return,
31 };
32 if elapsed < PROVIDER_WAIT_INCIDENT_SECS {
33 return;
34 }
35 let fanout = active_fanout_counts(app);
36 let pending_dispatch = app.pending_subagent_dispatch.is_some();
37 if fanout.is_none() && !pending_dispatch {
38 return;
39 }
40 let (fanout_running, fanout_total) = fanout.unwrap_or((0, 0));
41 app.provider_wait_incident_logged = true;
42 crate::logging::warn(format!(
43 "provider-wait incident: provider={} model={} elapsed_secs={elapsed} \
44 idle_secs={} stream_idle_budget_secs={} max_subagents={} \
45 fanout_running={fanout_running} fanout_total={fanout_total} \
46 running_agents={} pending_dispatch={pending_dispatch}",
47 app.provider_identity_for_persistence(),
48 app.model,
49 provider_wait_idle_secs(app),
50 app.stream_chunk_timeout_secs,
51 app.max_subagents,
52 running_agent_count(app),
53 ));
54 }
55
56 thread_local! {
57 /// Objective summaries keyed by agent id (#6213 T7). The objective is
58 /// immutable per agent, so `summarize_tool_output` — which JSON-parses the
59 /// whole assignment — only has to run once per agent instead of once per
60 /// `AgentProgress` event. `(length, hash)` of the objective guards the
61 /// memo, so even an id reuse with different text recomputes.
62 static OBJECTIVE_SUMMARIES: RefCell<HashMap<String, (usize, u64, String)>> =
63 RefCell::new(HashMap::new());
64 }
65
66 pub(crate) fn subagent_objective_summary(app: &App, id: &str) -> Option<String> {
67 let agent = app
68 .subagent_cache
69 .iter()
70 .find(|agent| agent.agent_id == id)?;
71 memoized_objective_summary(id, &agent.assignment.objective)
72 }
73
74 /// Memoized body of [`subagent_objective_summary`], split out so the memo can
75 /// be exercised without an `App`.
76 fn memoized_objective_summary(id: &str, objective: &str) -> Option<String> {
77 OBJECTIVE_SUMMARIES.with(|cache| {
78 let mut cache = cache.borrow_mut();
79 let (len, hash) = (objective.len(), hash_str(objective));
80 if let Some((cached_len, cached_hash, summary)) = cache.get(id)
81 && *cached_len == len
82 && *cached_hash == hash
83 {
84 return (!summary.is_empty()).then(|| summary.clone());
85 }
86 let summary = summarize_tool_output(objective);
87 // Bounded: live agents per session are few; a full map means the
88 // process has seen an unusual number of agents, so start over.
89 if cache.len() >= 256 {
90 cache.clear();
91 }
92 cache.insert(id.to_string(), (len, hash, summary.clone()));
93 (!summary.is_empty()).then_some(summary)
94 })
95 }
96
97 pub(crate) fn friendly_subagent_progress(
98 app: &App,
99 id: &str,
100 status: &str,
101 routine_wait: bool,
102 ) -> String {
103 if !routine_wait {
104 return summarize_tool_output(status);
105 }
106
107 if let Some(summary) = subagent_objective_summary(app, id) {
108 return format!("working on {summary}");
109 }
110 // Stored entries are always friendly rewrites (the event handler stores
111 // `display`, never raw text), so no content check is needed here.
112 if let Some(existing) = app.agent_progress.get(id)
113 && existing != "working"
114 && existing != "in the current"
115 {
116 return existing.clone();
117 }
118 "working".to_string()
119 }
120
121 pub(crate) fn one_line_summary(text: &str, max_width: usize) -> String {
122 let mut cleaned = String::with_capacity(text.len());
123 crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
124 truncate_line_to_width(
125 &cleaned.split_whitespace().collect::<Vec<_>>().join(" "),
126 max_width,
127 )
128 }
129
130 /// Objective + paused flag for the live goal, or `None` when no goal should
131 /// render (unset, or terminal Hunted/Escaped). Shared by the classic footer
132 /// chip and the ocean topbar chip so every shell surfaces the same state
133 /// (#39: the ocean shell has no sidebar, so without a topbar chip a goal set
134 /// via `create_goal` was invisible there).
135 pub(crate) fn active_goal_chip_state(app: &App) -> Option<(String, bool)> {
136 let (objective, paused) = match (&app.goal.objective, &app.paused_goal_objective) {
137 (Some(objective), _) => {
138 if matches!(
139 app.goal.status,
140 crate::tools::goal::GoalStatus::Complete | crate::tools::goal::GoalStatus::Blocked
141 ) {
142 return None;
143 }
144 (
145 objective.clone(),
146 app.goal.status == crate::tools::goal::GoalStatus::Paused,
147 )
148 }
149 (None, Some(objective)) => (objective.clone(), true),
150 (None, None) => return None,
151 };
152 if objective.trim().is_empty() {
153 return None;
154 }
155 Some((objective, paused))
156 }
157
158 pub(crate) fn format_token_count_compact(tokens: u64) -> String {
159 if tokens >= 1_000_000 {
160 format!("{:.1}M", tokens as f64 / 1_000_000.0)
161 } else if tokens >= 1_000 {
162 format!("{:.1}k", tokens as f64 / 1_000.0)
163 } else {
164 tokens.to_string()
165 }
166 }
167
168 #[cfg(test)]
169 pub(crate) fn format_context_budget(used: i64, max: u32) -> String {
170 let max_u64 = u64::from(max);
171 let max_i64 = i64::from(max);
172
173 if used > max_i64 {
174 return format!(
175 ">{}/{}",
176 format_token_count_compact(max_u64),
177 format_token_count_compact(max_u64)
178 );
179 }
180
181 let used_u64 = u64::try_from(used.max(0)).unwrap_or(0);
182 format!(
183 "{}/{}",
184 format_token_count_compact(used_u64),
185 format_token_count_compact(max_u64)
186 )
187 }
188
189 #[cfg(test)]
190 mod tests {
191 use super::{memoized_objective_summary, one_line_summary};
192
193 #[test]
194 fn one_line_summary_strips_ansi_before_collapsing_text() {
195 let summary = one_line_summary("read \x1b[38;2;6;174;242mfile.rs\x1b[0m", 80);
196 assert_eq!(summary, "read file.rs");
197 assert!(!summary.contains("38;2"));
198 }
199
200 #[test]
201 fn objective_summary_memo_revalidates_on_content_change() {
202 let id = "agent_memo_test";
203 // Both objectives have the same length, so only the content hash can
204 // tell them apart — the memo must not serve the first summary for the
205 // second objective.
206 let first = memoized_objective_summary(id, r#"{"message":"alpha"}"#);
207 assert_eq!(first.as_deref(), Some("alpha"));
208 let second = memoized_objective_summary(id, r#"{"message":"beta!"}"#);
209 assert_eq!(second.as_deref(), Some("beta!"));
210 // Unchanged objective: the memo path returns the same summary.
211 let again = memoized_objective_summary(id, r#"{"message":"beta!"}"#);
212 assert_eq!(again.as_deref(), Some("beta!"));
213 }
214 }
215
215 lines RUST