1use crate::context::AppContext;
2use crate::protocol::Response;
3
4pub fn finalize_response(
6 response: &mut Response,
7 ctx: &AppContext,
8 session_id: &str,
9 attach_command: &str,
10) {
11 finalize_response_with_bg_completions(response, ctx, session_id, attach_command, true);
12}
13
14pub fn finalize_response_with_bg_completions(
15 response: &mut Response,
16 ctx: &AppContext,
17 session_id: &str,
18 attach_command: &str,
19 allow_bg_completions: bool,
20) {
21 if allow_bg_completions {
22 attach_bg_completions(response, ctx, session_id, attach_command);
23 }
24 attach_status_bar(response, ctx, session_id, attach_command);
25}
26
27pub enum DispatchOutcome {
28 Immediate(Response),
29 Deferred(PendingResponse),
30}
31
32pub type PendingResponsePoll = Box<dyn FnMut(&AppContext) -> Option<Response>>;
33
34pub struct PendingResponse {
35 pub request_id: String,
36 pub session_id: String,
37 pub attach_command: String,
38 pub poll: PendingResponsePoll,
39}
40
41pub struct ResolvedPending {
42 pub response: Response,
43 pub session_id: String,
44 pub attach_command: String,
45}
46
47#[derive(Default)]
48pub struct PendingResponses {
49 entries: Vec<PendingResponse>,
50}
51
52impl PendingResponses {
53 pub fn register(&mut self, pending: PendingResponse) {
54 self.entries
55 .retain(|entry| entry.request_id != pending.request_id);
56 self.entries.push(pending);
57 }
58
59 pub fn poll_ready(&mut self, ctx: &AppContext) -> Vec<ResolvedPending> {
60 let mut ready = Vec::new();
61 let mut waiting = Vec::with_capacity(self.entries.len());
62
63 for mut pending in self.entries.drain(..) {
64 if let Some(response) = (pending.poll)(ctx) {
65 ready.push(ResolvedPending {
66 response,
67 session_id: pending.session_id,
68 attach_command: pending.attach_command,
69 });
70 } else {
71 waiting.push(pending);
72 }
73 }
74
75 self.entries = waiting;
76 ready
77 }
78
79 pub fn is_empty(&self) -> bool {
80 self.entries.is_empty()
81 }
82
83 pub fn drain_on_shutdown(&mut self) {
84 self.entries.clear();
85 }
86}
87
88pub fn attach_bg_completions(
89 response: &mut Response,
90 ctx: &AppContext,
91 session_id: &str,
92 command: &str,
93) {
94 if matches!(
95 command,
96 "configure"
97 | "bash_abort_inflight"
98 | "bash_status"
99 | "bash_write"
100 | "bash_promote"
101 | "bash_wait_detach"
102 | "bash_regex_match"
103 | "bash_drain_completions"
104 | "bash_notify"
105 | "bash_unnotify"
106 | "bash_ack_completions"
107 ) {
108 return;
109 }
110 if !ctx
111 .bash_background()
112 .has_completions_for_session(Some(session_id))
113 {
114 return;
115 }
116 let completions = ctx
117 .bash_background()
118 .drain_completions_for_session(Some(session_id));
119 if completions.is_empty() {
120 return;
121 }
122 let value = serde_json::json!(completions);
123 match response.data.as_object_mut() {
124 Some(data) => {
125 data.insert("bg_completions".to_string(), value);
126 }
127 None => {
128 response.data = serde_json::json!({ "bg_completions": value });
129 }
130 }
131}
132
133fn aft_status_segment(counts: &crate::context::StatusBarCounts) -> String {
134 let stale_mark = if counts.tier2_stale { "~" } else { "" };
135 format!(
136 "E{} W{} | {}D{} U{} C{} | T{}",
137 counts.errors,
138 counts.warnings,
139 stale_mark,
140 counts.dead_code,
141 counts.unused_exports,
142 counts.duplicates,
143 counts.todos
144 )
145}
146
147fn holder_owns_status_bar(plane_live: bool, harness: Option<&crate::harness::Harness>) -> bool {
148 plane_live && matches!(harness, Some(crate::harness::Harness::Opencode))
149}
150
151pub fn attach_status_bar(
159 response: &mut Response,
160 ctx: &AppContext,
161 session_id: &str,
162 command: &str,
163) {
164 if response
169 .data
170 .as_object_mut()
171 .and_then(|data| data.remove("_aft_suppress_status_bar"))
172 .is_some()
173 {
174 return;
175 }
176 if matches!(
177 command,
178 "configure"
179 | "ping"
180 | "version"
181 | "status"
182 | "bash_abort_inflight"
183 | "bash_status"
184 | "bash_write"
185 | "bash_promote"
186 | "bash_wait_detach"
187 | "bash_regex_match"
188 | "bash_drain_completions"
189 | "bash_notify"
190 | "bash_unnotify"
191 | "bash_ack_completions"
192 ) {
193 return;
194 }
195 let local_counts = ctx.status_bar_counts();
196 let harness = ctx.harness_opt();
197 let plane_live = ctx.fleet_status_client().is_some_and(|client| {
198 let config = ctx.config();
199 let Some(project_root) = config.project_root.as_deref() else {
200 return false;
201 };
202 let harness_label = harness
203 .as_ref()
204 .map(crate::harness::Harness::wire_label)
205 .unwrap_or_else(|| "unknown".to_string());
206 let aft_text = local_counts
207 .as_ref()
208 .map(aft_status_segment)
209 .unwrap_or_default();
210 client.publish(project_root, &harness_label, session_id, &aft_text)
211 });
212 if holder_owns_status_bar(plane_live, harness.as_ref()) {
213 return;
214 }
215 let Some(counts) = local_counts else {
216 return;
217 };
218 if !ctx.should_emit_status_bar(&counts) {
219 return;
220 }
221 let value = serde_json::json!({
222 "errors": counts.errors,
223 "warnings": counts.warnings,
224 "dead_code": counts.dead_code,
225 "unused_exports": counts.unused_exports,
226 "duplicates": counts.duplicates,
227 "todos": counts.todos,
228 "tier2_stale": counts.tier2_stale,
229 });
230 match response.data.as_object_mut() {
231 Some(data) => {
232 data.insert("status_bar".to_string(), value);
233 }
234 None => {
235 response.data = serde_json::json!({ "status_bar": value });
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::{aft_status_segment, holder_owns_status_bar};
243 use crate::context::StatusBarCounts;
244 use crate::harness::Harness;
245
246 #[test]
247 fn live_holder_retires_only_opencode_response_bars() {
248 assert_eq!(
249 (
250 holder_owns_status_bar(true, Some(&Harness::Opencode)),
251 holder_owns_status_bar(true, Some(&Harness::Runner)),
252 ),
253 (true, false)
254 );
255 assert!(!holder_owns_status_bar(true, Some(&Harness::Pi)));
256 assert!(!holder_owns_status_bar(false, Some(&Harness::Opencode)));
257 }
258
259 #[test]
260 fn solo_bar_bytes_remain_the_existing_golden() {
261 let counts = StatusBarCounts {
262 errors: 2,
263 warnings: 5,
264 dead_code: 331,
265 unused_exports: 221,
266 duplicates: 1159,
267 todos: 8,
268 tier2_stale: false,
269 };
270 assert_eq!(
271 format!("[AFT {}]", aft_status_segment(&counts)),
272 "[AFT E2 W5 | D331 U221 C1159 | T8]"
273 );
274 }
275
276 #[test]
277 fn solo_bar_stale_marker_bytes_remain_the_existing_golden() {
278 let counts = StatusBarCounts {
279 dead_code: 10,
280 tier2_stale: true,
281 ..StatusBarCounts::default()
282 };
283 assert_eq!(
284 format!("[AFT {}]", aft_status_segment(&counts)),
285 "[AFT E0 W0 | ~D10 U0 C0 | T0]"
286 );
287 }
288}