返回 CodeWhale
footer_ui.rs
根目录 / crates / tui / src / tui / footer_ui.rs
1 use crate::tui::app::App;
2 use crate::tui::history::summarize_tool_output;
3 use crate::tui::subagent_routing::{active_fanout_counts, running_agent_count};
4 use crate::tui::ui_text::truncate_line_to_width;
5
6 /// Seconds the current turn has gone without observable stream activity.
7 pub(crate) fn provider_wait_idle_secs(app: &App) -> u64 {
8 app.turn_last_activity_at
9 .or(app.turn_started_at)
10 .map(|at| at.elapsed().as_secs())
11 .unwrap_or(0)
12 }
13
14 /// Threshold after which a provider wait with a planned fanout is logged as
15 /// a structured incident (once per turn).
16 const PROVIDER_WAIT_INCIDENT_SECS: u64 = 120;
17
18 /// Log a compact structured incident when the parent turn has spent a long
19 /// time in provider wait while a sub-agent fanout plan is present (#3095).
20 pub(crate) fn maybe_log_provider_wait_incident(app: &mut App) {
21 if app.provider_wait_incident_logged || !app.is_loading {
22 return;
23 }
24 let elapsed = match app.turn_started_at {
25 Some(at) => at.elapsed().as_secs(),
26 None => return,
27 };
28 if elapsed < PROVIDER_WAIT_INCIDENT_SECS {
29 return;
30 }
31 let fanout = active_fanout_counts(app);
32 let pending_dispatch = app.pending_subagent_dispatch.is_some();
33 if fanout.is_none() && !pending_dispatch {
34 return;
35 }
36 let (fanout_running, fanout_total) = fanout.unwrap_or((0, 0));
37 app.provider_wait_incident_logged = true;
38 crate::logging::warn(format!(
39 "provider-wait incident: provider={} model={} elapsed_secs={elapsed} \
40 idle_secs={} stream_idle_budget_secs={} max_subagents={} \
41 fanout_running={fanout_running} fanout_total={fanout_total} \
42 running_agents={} pending_dispatch={pending_dispatch}",
43 app.provider_identity_for_persistence(),
44 app.model,
45 provider_wait_idle_secs(app),
46 app.stream_chunk_timeout_secs,
47 app.max_subagents,
48 running_agent_count(app),
49 ));
50 }
51
52 pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool {
53 let status = status.trim().to_ascii_lowercase();
54 status.contains("requesting model response")
55 }
56
57 pub(crate) fn subagent_objective_summary(app: &App, id: &str) -> Option<String> {
58 app.subagent_cache
59 .iter()
60 .find(|agent| agent.agent_id == id)
61 .map(|agent| summarize_tool_output(&agent.assignment.objective))
62 .filter(|summary| !summary.is_empty())
63 }
64
65 pub(crate) fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String {
66 if !is_noisy_subagent_progress(status) {
67 return summarize_tool_output(status);
68 }
69
70 if let Some(summary) = subagent_objective_summary(app, id) {
71 return format!("working on {summary}");
72 }
73 if let Some(existing) = app.agent_progress.get(id)
74 && !is_noisy_subagent_progress(existing)
75 && existing != "working"
76 {
77 return existing.clone();
78 }
79 "working".to_string()
80 }
81
82 pub(crate) fn one_line_summary(text: &str, max_width: usize) -> String {
83 let mut cleaned = String::with_capacity(text.len());
84 crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
85 truncate_line_to_width(
86 &cleaned.split_whitespace().collect::<Vec<_>>().join(" "),
87 max_width,
88 )
89 }
90
91 /// Objective + paused flag for the live goal, or `None` when no goal should
92 /// render (unset, or terminal Hunted/Escaped). Shared by the classic footer
93 /// chip and the ocean topbar chip so every shell surfaces the same state
94 /// (#39: the ocean shell has no sidebar, so without a topbar chip a goal set
95 /// via `create_goal` was invisible there).
96 pub(crate) fn active_goal_chip_state(app: &App) -> Option<(String, bool)> {
97 let (objective, paused) = match (&app.hunt.quarry, &app.paused_quarry) {
98 (Some(objective), _) => {
99 if matches!(
100 app.hunt.verdict,
101 crate::tui::app::HuntVerdict::Hunted | crate::tui::app::HuntVerdict::Escaped
102 ) {
103 return None;
104 }
105 (
106 objective.clone(),
107 app.hunt.verdict == crate::tui::app::HuntVerdict::Wounded,
108 )
109 }
110 (None, Some(objective)) => (objective.clone(), true),
111 (None, None) => return None,
112 };
113 if objective.trim().is_empty() {
114 return None;
115 }
116 Some((objective, paused))
117 }
118
119 pub(crate) fn format_token_count_compact(tokens: u64) -> String {
120 if tokens >= 1_000_000 {
121 format!("{:.1}M", tokens as f64 / 1_000_000.0)
122 } else if tokens >= 1_000 {
123 format!("{:.1}k", tokens as f64 / 1_000.0)
124 } else {
125 tokens.to_string()
126 }
127 }
128
129 #[cfg(test)]
130 pub(crate) fn format_context_budget(used: i64, max: u32) -> String {
131 let max_u64 = u64::from(max);
132 let max_i64 = i64::from(max);
133
134 if used > max_i64 {
135 return format!(
136 ">{}/{}",
137 format_token_count_compact(max_u64),
138 format_token_count_compact(max_u64)
139 );
140 }
141
142 let used_u64 = u64::try_from(used.max(0)).unwrap_or(0);
143 format!(
144 "{}/{}",
145 format_token_count_compact(used_u64),
146 format_token_count_compact(max_u64)
147 )
148 }
149
150 #[cfg(test)]
151 mod tests {
152 use super::one_line_summary;
153
154 #[test]
155 fn one_line_summary_strips_ansi_before_collapsing_text() {
156 let summary = one_line_summary("read \x1b[38;2;6;174;242mfile.rs\x1b[0m", 80);
157 assert_eq!(summary, "read file.rs");
158 assert!(!summary.contains("38;2"));
159 }
160 }
161
161 lines RUST