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> + Send>;
104pub type PendingResponseShutdown = Box<dyn FnMut(&AppContext) -> Response + Send>;
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 cancellation: Option<crate::executor::JobCancellation>,
115 pub on_shutdown: Option<PendingResponseShutdown>,
119}
120
121pub struct ResolvedPending {
122 pub response: Response,
123 pub session_id: String,
124 pub attach_command: String,
125}
126
127#[derive(Default)]
128pub struct PendingResponses {
129 entries: Vec<PendingResponse>,
130}
131
132impl PendingResponses {
133 pub fn register(&mut self, pending: PendingResponse) {
134 self.entries.retain(|entry| {
135 let keep = entry.request_id != pending.request_id;
136 if !keep {
137 if let Some(cancellation) = &entry.cancellation {
138 cancellation.request_cancel();
139 }
140 }
141 keep
142 });
143 self.entries.push(pending);
144 }
145
146 pub fn cancel_request(&mut self, request_id: &str) -> bool {
149 let Some(entry) = self
150 .entries
151 .iter()
152 .find(|entry| entry.request_id == request_id)
153 else {
154 return false;
155 };
156 let Some(cancellation) = &entry.cancellation else {
157 return false;
158 };
159 cancellation.request_cancel();
160 true
161 }
162
163 pub fn poll_ready(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
164 let mut ready = Vec::new();
165 let mut waiting = Vec::with_capacity(self.entries.len());
166
167 for mut pending in self.entries.drain(..) {
168 if let Some(response) = (pending.poll)(ctx) {
169 ready.push(ResolvedPending {
170 response,
171 session_id: pending.session_id,
172 attach_command: pending.attach_command,
173 });
174 } else {
175 waiting.push(pending);
176 }
177 }
178
179 self.entries = waiting;
180 ready
181 }
182
183 pub fn is_empty(&self) -> bool {
184 self.entries.is_empty()
185 }
186
187 pub fn drain_on_shutdown(&mut self) {
188 for pending in self.entries.drain(..) {
189 if let Some(cancellation) = &pending.cancellation {
190 cancellation.request_cancel();
191 }
192 }
193 }
194
195 pub fn drain_on_shutdown_with(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
198 self.entries
199 .drain(..)
200 .filter_map(|mut pending| {
201 if let Some(cancellation) = &pending.cancellation {
202 cancellation.request_cancel();
203 }
204 let response = (pending.on_shutdown.as_mut()?)(ctx);
205 Some(ResolvedPending {
206 response,
207 session_id: pending.session_id,
208 attach_command: pending.attach_command,
209 })
210 })
211 .collect()
212 }
213}
214
215pub fn attach_bg_completions(
216 response: &mut Response,
217 ctx: &AppContext,
218 session_id: &str,
219 command: &str,
220) {
221 if matches!(
222 command,
223 "configure"
224 | "bash_abort_inflight"
225 | "bash_status"
226 | "bash_write"
227 | "bash_promote"
228 | "bash_wait_detach"
229 | "bash_regex_match"
230 | "bash_drain_completions"
231 | "bash_notify"
232 | "bash_unnotify"
233 | "bash_ack_completions"
234 ) {
235 return;
236 }
237 if !ctx
238 .bash_background()
239 .has_completions_for_session(Some(session_id))
240 {
241 return;
242 }
243 let completions = ctx
244 .bash_background()
245 .drain_completions_for_session(Some(session_id));
246 if completions.is_empty() {
247 return;
248 }
249 let value = serde_json::json!(completions);
250 match response.data.as_object_mut() {
251 Some(data) => {
252 data.insert("bg_completions".to_string(), value);
253 }
254 None => {
255 response.data = serde_json::json!({ "bg_completions": value });
256 }
257 }
258}
259
260fn aft_status_segment(counts: &crate::context::StatusBarCounts) -> String {
261 let stale_mark = if counts.tier2_stale { "~" } else { "" };
262 format!(
266 "AFT E{} W{} | {}D{} U{} C{} | T{}",
267 counts.errors,
268 counts.warnings,
269 stale_mark,
270 counts.dead_code,
271 counts.unused_exports,
272 counts.duplicates,
273 counts.todos
274 )
275}
276
277fn holder_owns_status_bar(plane_live: bool, harness: Option<&crate::harness::Harness>) -> bool {
278 plane_live && matches!(harness, Some(crate::harness::Harness::Opencode))
279}
280
281fn publish_fleet_status(
284 response: &mut Response,
285 ctx: &AppContext,
286 session_id: &str,
287) -> Option<bool> {
288 if response
291 .data
292 .as_object_mut()
293 .and_then(|data| data.remove("_aft_suppress_status_bar"))
294 .is_some()
295 {
296 return None;
297 }
298
299 let local_counts = ctx.status_bar_counts();
300 let harness = ctx.harness_opt();
301 let plane_live = ctx.fleet_status_client().is_some_and(|client| {
302 let config = ctx.config();
303 let Some(project_root) = config.project_root.as_deref() else {
304 return false;
305 };
306 let harness_label = harness
307 .as_ref()
308 .map(crate::harness::Harness::wire_label)
309 .unwrap_or_else(|| "unknown".to_string());
310 let aft_text = local_counts
311 .as_ref()
312 .map(aft_status_segment)
313 .unwrap_or_default();
314 client.publish(project_root, &harness_label, session_id, &aft_text)
315 });
316 Some(plane_live)
317}
318
319pub fn attach_status_bar(
322 response: &mut Response,
323 ctx: &AppContext,
324 session_id: &str,
325 command: &str,
326) {
327 if alert_render::is_excluded_finalization_command(command) {
328 return;
329 }
330 let plane_live = publish_fleet_status(response, ctx, session_id);
331 attach_status_bar_after_publish(response, ctx, plane_live);
332}
333
334fn attach_status_bar_after_publish(
335 response: &mut Response,
336 ctx: &AppContext,
337 plane_live: Option<bool>,
338) {
339 let Some(plane_live) = plane_live else {
340 return;
341 };
342 let harness = ctx.harness_opt();
343 if holder_owns_status_bar(plane_live, harness.as_ref()) {
344 return;
345 }
346 let Some(counts) = ctx.status_bar_counts() else {
347 return;
348 };
349 if !ctx.should_emit_status_bar(&counts) {
350 return;
351 }
352 let value = serde_json::json!({
353 "errors": counts.errors,
354 "warnings": counts.warnings,
355 "dead_code": counts.dead_code,
356 "unused_exports": counts.unused_exports,
357 "duplicates": counts.duplicates,
358 "todos": counts.todos,
359 "tier2_stale": counts.tier2_stale,
360 });
361 match response.data.as_object_mut() {
362 Some(data) => {
363 data.insert("status_bar".to_string(), value);
364 }
365 None => {
366 response.data = serde_json::json!({ "status_bar": value });
367 }
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use std::path::PathBuf;
374
375 use super::{
376 aft_status_segment, finalize_response_with_bg_completions, holder_owns_status_bar,
377 PendingResponse, PendingResponses,
378 };
379 use crate::config::Config;
380 use crate::context::{AppContext, StatusBarCounts};
381 use crate::fleet_status::FleetStatusClient;
382 use crate::harness::Harness;
383 use crate::parser::TreeSitterProvider;
384 use crate::protocol::Response;
385
386 #[test]
387 fn live_holder_retires_only_opencode_response_bars() {
388 assert_eq!(
389 (
390 holder_owns_status_bar(true, Some(&Harness::Opencode)),
391 holder_owns_status_bar(true, Some(&Harness::Runner)),
392 ),
393 (true, false)
394 );
395 assert!(!holder_owns_status_bar(true, Some(&Harness::Pi)));
396 assert!(!holder_owns_status_bar(false, Some(&Harness::Opencode)));
397 }
398
399 #[test]
400 fn pre_discovery_publish_does_not_trip_the_holder_ownership_gate() {
401 let ctx = AppContext::new(
402 Box::new(TreeSitterProvider::new()),
403 Config {
404 project_root: Some(PathBuf::from("/tmp/project")),
405 ..Config::default()
406 },
407 );
408 ctx.set_harness(Harness::Opencode);
409 ctx.update_status_bar_tier2(Some(21), Some(12), Some(13), Some(14), false);
410 let (client, mut wire_rx) = FleetStatusClient::dial_channel(1);
411 ctx.install_fleet_status_client(Some(client));
412 let mut response = Response::success("status", serde_json::json!({}));
413
414 finalize_response_with_bg_completions(&mut response, &ctx, "session-1", "echo", false);
415
416 assert_eq!(response.data["status_bar"]["dead_code"], 21);
417 let publish = wire_rx.try_recv().expect("single discovery publish");
418 assert_eq!(publish.body()["text"], "AFT E0 W0 | D21 U12 C13 | T14");
419 assert!(
420 wire_rx.try_recv().is_err(),
421 "response published more than once"
422 );
423 publish.complete_unavailable();
424 }
425
426 #[test]
427 fn published_segment_bytes_are_self_labeled() {
428 let counts = StatusBarCounts {
429 errors: 2,
430 warnings: 5,
431 dead_code: 331,
432 unused_exports: 221,
433 duplicates: 1159,
434 todos: 8,
435 tier2_stale: false,
436 };
437 assert_eq!(
438 format!("[{}]", aft_status_segment(&counts)),
439 "[AFT E2 W5 | D331 U221 C1159 | T8]"
440 );
441 }
442
443 #[test]
444 fn shutdown_delivery_emits_terminal_before_removing_entry() {
445 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
446 let mut pending = PendingResponses::default();
447 pending.register(PendingResponse {
448 request_id: "inspect-shutdown".to_string(),
449 session_id: String::new(),
450 attach_command: String::new(),
451 poll: Box::new(|_| None),
452 cancellation: None,
453 on_shutdown: Some(Box::new(|_| {
454 Response::error("inspect-shutdown", "daemon_shutdown", "shutdown")
455 })),
456 });
457
458 let resolved = pending.drain_on_shutdown_with(&ctx);
459 assert_eq!(resolved.len(), 1);
460 assert_eq!(resolved[0].response.id, "inspect-shutdown");
461 assert!(pending.is_empty());
462 }
463
464 #[test]
465 fn published_segment_stale_marker_bytes_are_self_labeled() {
466 let counts = StatusBarCounts {
467 dead_code: 10,
468 tier2_stale: true,
469 ..StatusBarCounts::default()
470 };
471 assert_eq!(
472 format!("[{}]", aft_status_segment(&counts)),
473 "[AFT E0 W0 | ~D10 U0 C0 | T0]"
474 );
475 }
476}