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