1#[path = "alert_render.rs"]
2pub mod alert_render;
3
4use std::path::Path;
5
6use crate::context::AppContext;
7use crate::protocol::Response;
8
9pub fn finalize_response(
13 response: &mut Response,
14 ctx: &AppContext,
15 session_id: &str,
16 attach_command: &str,
17) {
18 finalize_response_with_bg_completions(response, ctx, session_id, attach_command, true);
19}
20
21pub fn finalize_response_with_bg_completions(
24 response: &mut Response,
25 ctx: &AppContext,
26 session_id: &str,
27 attach_command: &str,
28 allow_bg_completions: bool,
29) {
30 if allow_bg_completions {
31 attach_bg_completions(response, ctx, session_id, attach_command);
32 }
33 let plane_live = publish_fleet_status(response, ctx, session_id);
34
35 if response.data.get("text").is_none()
39 && !alert_render::is_excluded_finalization_command(attach_command)
40 {
41 attach_status_bar_after_publish(response, ctx, plane_live);
42 }
43}
44
45pub fn finalize_response_for_dispatch_root(
48 response: &mut Response,
49 ctx: &AppContext,
50 alerts: &mut alert_render::AlertEngine,
51 session_id: &str,
52 dispatch_root: &Path,
53 attach_command: &str,
54 allow_bg_completions: bool,
55) {
56 if allow_bg_completions {
57 attach_bg_completions(response, ctx, session_id, attach_command);
58 }
59 let _ = publish_fleet_status(response, ctx, session_id);
60 attach_alert_block(response, alerts, session_id, dispatch_root, attach_command);
61}
62
63fn attach_alert_block(
64 response: &mut Response,
65 alerts: &mut alert_render::AlertEngine,
66 session_id: &str,
67 dispatch_root: &Path,
68 command: &str,
69) {
70 let Some(text) = response
71 .data
72 .as_object_mut()
73 .and_then(|data| data.get_mut("text"))
74 .and_then(|value| value.as_str())
75 .map(str::to_string)
76 else {
77 return;
78 };
79
80 if text.contains("<system-reminder>") {
83 return;
84 }
85 let Some(alert) = alerts.finalize(session_id, dispatch_root, command) else {
86 return;
87 };
88 let joined = if text.is_empty() {
89 alert.text
90 } else {
91 format!("{text}\n\n{}", alert.text)
92 };
93 if let Some(data) = response.data.as_object_mut() {
94 data.insert("text".to_string(), serde_json::Value::String(joined));
95 }
96}
97
98pub enum DispatchOutcome {
99 Immediate(Response),
100 Deferred(PendingResponse),
101}
102
103pub type PendingResponsePoll = Box<dyn FnMut(&AppContext) -> Option<Response>>;
104pub type PendingResponseShutdown = Box<dyn FnMut(&AppContext) -> Response>;
105
106pub struct PendingResponse {
107 pub request_id: String,
108 pub session_id: String,
109 pub attach_command: String,
110 pub poll: PendingResponsePoll,
111 pub on_shutdown: Option<PendingResponseShutdown>,
115}
116
117pub struct ResolvedPending {
118 pub response: Response,
119 pub session_id: String,
120 pub attach_command: String,
121}
122
123#[derive(Default)]
124pub struct PendingResponses {
125 entries: Vec<PendingResponse>,
126}
127
128impl PendingResponses {
129 pub fn register(&mut self, pending: PendingResponse) {
130 self.entries
131 .retain(|entry| entry.request_id != pending.request_id);
132 self.entries.push(pending);
133 }
134
135 pub fn poll_ready(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
136 let mut ready = Vec::new();
137 let mut waiting = Vec::with_capacity(self.entries.len());
138
139 for mut pending in self.entries.drain(..) {
140 if let Some(response) = (pending.poll)(ctx) {
141 ready.push(ResolvedPending {
142 response,
143 session_id: pending.session_id,
144 attach_command: pending.attach_command,
145 });
146 } else {
147 waiting.push(pending);
148 }
149 }
150
151 self.entries = waiting;
152 ready
153 }
154
155 pub fn is_empty(&self) -> bool {
156 self.entries.is_empty()
157 }
158
159 pub fn drain_on_shutdown(&mut self) {
160 self.entries.clear();
161 }
162
163 pub fn drain_on_shutdown_with(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
166 self.entries
167 .drain(..)
168 .filter_map(|mut pending| {
169 let response = (pending.on_shutdown.as_mut()?)(ctx);
170 Some(ResolvedPending {
171 response,
172 session_id: pending.session_id,
173 attach_command: pending.attach_command,
174 })
175 })
176 .collect()
177 }
178}
179
180pub fn attach_bg_completions(
181 response: &mut Response,
182 ctx: &AppContext,
183 session_id: &str,
184 command: &str,
185) {
186 if matches!(
187 command,
188 "configure"
189 | "bash_abort_inflight"
190 | "bash_status"
191 | "bash_write"
192 | "bash_promote"
193 | "bash_wait_detach"
194 | "bash_regex_match"
195 | "bash_drain_completions"
196 | "bash_notify"
197 | "bash_unnotify"
198 | "bash_ack_completions"
199 ) {
200 return;
201 }
202 if !ctx
203 .bash_background()
204 .has_completions_for_session(Some(session_id))
205 {
206 return;
207 }
208 let completions = ctx
209 .bash_background()
210 .drain_completions_for_session(Some(session_id));
211 if completions.is_empty() {
212 return;
213 }
214 let value = serde_json::json!(completions);
215 match response.data.as_object_mut() {
216 Some(data) => {
217 data.insert("bg_completions".to_string(), value);
218 }
219 None => {
220 response.data = serde_json::json!({ "bg_completions": value });
221 }
222 }
223}
224
225fn aft_status_segment(counts: &crate::context::StatusBarCounts) -> String {
226 let stale_mark = if counts.tier2_stale { "~" } else { "" };
227 format!(
228 "E{} W{} | {}D{} U{} C{} | T{}",
229 counts.errors,
230 counts.warnings,
231 stale_mark,
232 counts.dead_code,
233 counts.unused_exports,
234 counts.duplicates,
235 counts.todos
236 )
237}
238
239fn holder_owns_status_bar(plane_live: bool, harness: Option<&crate::harness::Harness>) -> bool {
240 plane_live && matches!(harness, Some(crate::harness::Harness::Opencode))
241}
242
243fn publish_fleet_status(
246 response: &mut Response,
247 ctx: &AppContext,
248 session_id: &str,
249) -> Option<bool> {
250 if response
253 .data
254 .as_object_mut()
255 .and_then(|data| data.remove("_aft_suppress_status_bar"))
256 .is_some()
257 {
258 return None;
259 }
260
261 let local_counts = ctx.status_bar_counts();
262 let harness = ctx.harness_opt();
263 let plane_live = ctx.fleet_status_client().is_some_and(|client| {
264 let config = ctx.config();
265 let Some(project_root) = config.project_root.as_deref() else {
266 return false;
267 };
268 let harness_label = harness
269 .as_ref()
270 .map(crate::harness::Harness::wire_label)
271 .unwrap_or_else(|| "unknown".to_string());
272 let aft_text = local_counts
273 .as_ref()
274 .map(aft_status_segment)
275 .unwrap_or_default();
276 client.publish(project_root, &harness_label, session_id, &aft_text)
277 });
278 Some(plane_live)
279}
280
281pub fn attach_status_bar(
284 response: &mut Response,
285 ctx: &AppContext,
286 session_id: &str,
287 command: &str,
288) {
289 if alert_render::is_excluded_finalization_command(command) {
290 return;
291 }
292 let plane_live = publish_fleet_status(response, ctx, session_id);
293 attach_status_bar_after_publish(response, ctx, plane_live);
294}
295
296fn attach_status_bar_after_publish(
297 response: &mut Response,
298 ctx: &AppContext,
299 plane_live: Option<bool>,
300) {
301 let Some(plane_live) = plane_live else {
302 return;
303 };
304 let harness = ctx.harness_opt();
305 if holder_owns_status_bar(plane_live, harness.as_ref()) {
306 return;
307 }
308 let Some(counts) = ctx.status_bar_counts() else {
309 return;
310 };
311 if !ctx.should_emit_status_bar(&counts) {
312 return;
313 }
314 let value = serde_json::json!({
315 "errors": counts.errors,
316 "warnings": counts.warnings,
317 "dead_code": counts.dead_code,
318 "unused_exports": counts.unused_exports,
319 "duplicates": counts.duplicates,
320 "todos": counts.todos,
321 "tier2_stale": counts.tier2_stale,
322 });
323 match response.data.as_object_mut() {
324 Some(data) => {
325 data.insert("status_bar".to_string(), value);
326 }
327 None => {
328 response.data = serde_json::json!({ "status_bar": value });
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use std::path::PathBuf;
336
337 use super::{
338 aft_status_segment, finalize_response_with_bg_completions, holder_owns_status_bar,
339 PendingResponse, PendingResponses,
340 };
341 use crate::config::Config;
342 use crate::context::{AppContext, StatusBarCounts};
343 use crate::fleet_status::FleetStatusClient;
344 use crate::harness::Harness;
345 use crate::parser::TreeSitterProvider;
346 use crate::protocol::Response;
347
348 #[test]
349 fn live_holder_retires_only_opencode_response_bars() {
350 assert_eq!(
351 (
352 holder_owns_status_bar(true, Some(&Harness::Opencode)),
353 holder_owns_status_bar(true, Some(&Harness::Runner)),
354 ),
355 (true, false)
356 );
357 assert!(!holder_owns_status_bar(true, Some(&Harness::Pi)));
358 assert!(!holder_owns_status_bar(false, Some(&Harness::Opencode)));
359 }
360
361 #[test]
362 fn pre_discovery_publish_does_not_trip_the_holder_ownership_gate() {
363 let ctx = AppContext::new(
364 Box::new(TreeSitterProvider::new()),
365 Config {
366 project_root: Some(PathBuf::from("/tmp/project")),
367 ..Config::default()
368 },
369 );
370 ctx.set_harness(Harness::Opencode);
371 ctx.update_status_bar_tier2(Some(21), Some(12), Some(13), Some(14), false);
372 let (client, mut wire_rx) = FleetStatusClient::dial_channel(1);
373 ctx.install_fleet_status_client(Some(client));
374 let mut response = Response::success("status", serde_json::json!({}));
375
376 finalize_response_with_bg_completions(&mut response, &ctx, "session-1", "echo", false);
377
378 assert_eq!(response.data["status_bar"]["dead_code"], 21);
379 let publish = wire_rx.try_recv().expect("single discovery publish");
380 assert_eq!(publish.body()["text"], "E0 W0 | D21 U12 C13 | T14");
381 assert!(
382 wire_rx.try_recv().is_err(),
383 "response published more than once"
384 );
385 publish.complete_unavailable();
386 }
387
388 #[test]
389 fn solo_bar_bytes_remain_the_existing_golden() {
390 let counts = StatusBarCounts {
391 errors: 2,
392 warnings: 5,
393 dead_code: 331,
394 unused_exports: 221,
395 duplicates: 1159,
396 todos: 8,
397 tier2_stale: false,
398 };
399 assert_eq!(
400 format!("[AFT {}]", aft_status_segment(&counts)),
401 "[AFT E2 W5 | D331 U221 C1159 | T8]"
402 );
403 }
404
405 #[test]
406 fn shutdown_delivery_emits_terminal_before_removing_entry() {
407 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
408 let mut pending = PendingResponses::default();
409 pending.register(PendingResponse {
410 request_id: "inspect-shutdown".to_string(),
411 session_id: String::new(),
412 attach_command: String::new(),
413 poll: Box::new(|_| None),
414 on_shutdown: Some(Box::new(|_| {
415 Response::error("inspect-shutdown", "daemon_shutdown", "shutdown")
416 })),
417 });
418
419 let resolved = pending.drain_on_shutdown_with(&ctx);
420 assert_eq!(resolved.len(), 1);
421 assert_eq!(resolved[0].response.id, "inspect-shutdown");
422 assert!(pending.is_empty());
423 }
424
425 #[test]
426 fn solo_bar_stale_marker_bytes_remain_the_existing_golden() {
427 let counts = StatusBarCounts {
428 dead_code: 10,
429 tier2_stale: true,
430 ..StatusBarCounts::default()
431 };
432 assert_eq!(
433 format!("[AFT {}]", aft_status_segment(&counts)),
434 "[AFT E0 W0 | ~D10 U0 C0 | T0]"
435 );
436 }
437}