1use std::io::IsTerminal;
2
3use serde::Deserialize;
4
5use crate::config::Config;
6use crate::subprocess::Tool;
7
8#[derive(Debug, Deserialize)]
11pub struct InboxResponse {
12 pub total_unread: i32,
13 pub channels: Option<Vec<InboxChannel>>,
14}
15
16#[derive(Debug, Deserialize)]
17pub struct InboxChannel {
18 pub messages: Option<Vec<InboxMessage>>,
19}
20
21#[derive(Debug, Deserialize)]
22pub struct InboxMessage {
23 pub agent: String,
24 pub label: Option<String>,
25 pub body: String,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct ReviewsResponse {
30 pub reviews_awaiting_vote: Option<Vec<ReviewInfo>>,
31 pub threads_with_new_responses: Option<Vec<ThreadInfo>>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct ReviewInfo {
36 pub review_id: String,
37 pub title: Option<String>,
38 pub description: Option<String>,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct ThreadInfo {}
43
44#[derive(Debug, Deserialize)]
45pub struct ClaimsResponse {
46 pub claims: Option<Vec<Claim>>,
47}
48
49#[derive(Debug, Deserialize)]
50pub struct Claim {
51 pub patterns: Option<Vec<String>>,
52 pub expires_in_secs: Option<i32>,
53}
54
55pub struct Colors {
57 pub reset: &'static str,
58 pub bold: &'static str,
59 pub dim: &'static str,
60 pub cyan: &'static str,
61 pub green: &'static str,
62}
63
64impl Colors {
65 pub fn detect() -> Self {
66 if std::io::stdout().is_terminal() {
67 Self {
68 reset: "\x1b[0m",
69 bold: "\x1b[1m",
70 dim: "\x1b[2m",
71 cyan: "\x1b[36m",
72 green: "\x1b[32m",
73 }
74 } else {
75 Self {
76 reset: "",
77 bold: "",
78 dim: "",
79 cyan: "",
80 green: "",
81 }
82 }
83 }
84}
85
86pub fn h1(c: &Colors, s: &str) -> String {
87 format!("{}{}# {}{}", c.bold, c.cyan, s, c.reset)
88}
89
90pub fn h2(c: &Colors, s: &str) -> String {
91 format!("{}{}## {}{}", c.bold, c.green, s, c.reset)
92}
93
94pub fn hint(c: &Colors, s: &str) -> String {
95 format!("{}> {}{}", c.dim, s, c.reset)
96}
97
98fn load_config() -> anyhow::Result<Config> {
100 let cwd = std::path::Path::new(".");
101 let (config_path, _) = crate::config::find_config_in_project(cwd)?;
102 Config::load(&config_path)
103}
104
105fn run_json_tool(tool: &str, args: &[&str]) -> Option<String> {
107 if tool == "bn" || tool == "crit" {
108 let mut output = Tool::new(tool);
110 for arg in args {
111 output = output.arg(arg);
112 }
113 output = output.arg("--format").arg("json");
114
115 let result = output.in_workspace("default").ok()?.run().ok()?;
116
117 if result.success() {
118 Some(result.stdout)
119 } else {
120 None
121 }
122 } else {
123 let mut output = Tool::new(tool);
125 for arg in args {
126 output = output.arg(arg);
127 }
128 output = output.arg("--format").arg("json");
129
130 let result = output.run().ok()?;
131 if result.success() {
132 Some(result.stdout)
133 } else {
134 None
135 }
136 }
137}
138
139pub fn run_iteration_start(agent_override: Option<&str>) -> anyhow::Result<()> {
141 let config = load_config()?;
142 let default_agent = config.default_agent();
143 let agent = agent_override.unwrap_or(default_agent.as_str());
144 let project = config.channel();
145 let c = Colors::detect();
146
147 println!("{}", h1(&c, &format!("Iteration Start: {}", agent)));
148 println!();
149
150 let inbox_output = run_json_tool("bus", &["inbox", "--agent", agent, "--channels", &project]);
152 let mut unread_count = 0;
153
154 if let Some(output) = &inbox_output
155 && let Ok(inbox) = serde_json::from_str::<InboxResponse>(output)
156 {
157 unread_count = inbox.total_unread;
158 }
159
160 println!(
161 "{}",
162 h2(&c, &format!("Unread Bus Messages ({})", unread_count))
163 );
164
165 if let Some(output) = inbox_output {
166 if let Ok(inbox) = serde_json::from_str::<InboxResponse>(&output) {
167 if inbox.total_unread > 0 {
168 if let Some(channels) = inbox.channels {
169 for channel in channels {
170 if let Some(messages) = channel.messages {
171 for msg in messages.iter().take(5) {
172 let label = msg
173 .label
174 .as_ref()
175 .map(|l| format!("[{}]", l))
176 .unwrap_or_default();
177 let body = if msg.body.len() > 60 {
178 format!("{}...", &msg.body[..msg.body.floor_char_boundary(60)])
179 } else {
180 msg.body.clone()
181 };
182 println!(
183 " {}{}{} {}: {}",
184 c.dim, msg.agent, c.reset, label, body
185 );
186 }
187 }
188 }
189 }
190 } else {
191 println!(" {}No unread messages{}", c.dim, c.reset);
192 }
193 } else {
194 println!(" {}No unread messages{}", c.dim, c.reset);
195 }
196 } else {
197 println!(" {}No unread messages{}", c.dim, c.reset);
198 }
199 println!();
200
201 if let Err(e) = super::triage::run_triage() {
203 println!(" {}Could not fetch triage data: {}{}", c.dim, e, c.reset);
204 }
205 println!();
206
207 println!("{}", h2(&c, "Pending Reviews"));
209 let reviews_output = run_json_tool("crit", &["inbox", "--agent", agent]);
210 let mut has_reviews = false;
211
212 if let Some(output) = reviews_output {
213 if let Ok(reviews) = serde_json::from_str::<ReviewsResponse>(&output) {
214 let awaiting = reviews.reviews_awaiting_vote.unwrap_or_default();
215 let threads = reviews.threads_with_new_responses.unwrap_or_default();
216
217 if !awaiting.is_empty() || !threads.is_empty() {
218 has_reviews = true;
219 if !awaiting.is_empty() {
220 println!(" {} review(s) awaiting vote", awaiting.len());
221 for r in awaiting.iter().take(3) {
222 let no_title = "(no title)".to_string();
223 let title = r
224 .title
225 .as_ref()
226 .or(r.description.as_ref())
227 .unwrap_or(&no_title);
228 println!(" {}: {}", r.review_id, title);
229 }
230 }
231 if !threads.is_empty() {
232 println!(" {} thread(s) with new responses", threads.len());
233 }
234 } else {
235 println!(" {}No pending reviews{}", c.dim, c.reset);
236 }
237 } else {
238 println!(" {}No pending reviews{}", c.dim, c.reset);
239 }
240 } else {
241 println!(" {}Could not fetch reviews{}", c.dim, c.reset);
242 }
243 println!();
244
245 println!("{}", h2(&c, "Active Claims"));
247 let claims_output = run_json_tool("bus", &["claims", "list", "--agent", agent, "--mine"]);
248
249 if let Some(output) = claims_output {
250 if let Ok(claims_data) = serde_json::from_str::<ClaimsResponse>(&output) {
251 if let Some(claims) = claims_data.claims {
252 let resource_claims: Vec<_> = claims
254 .iter()
255 .filter(|cl| {
256 cl.patterns
257 .as_ref()
258 .map(|p| !p.iter().all(|pat| pat.starts_with("agent://")))
259 .unwrap_or(true)
260 })
261 .collect();
262
263 if !resource_claims.is_empty() {
264 println!(" {} active claim(s)", resource_claims.len());
265 for claim in resource_claims.iter().take(5) {
266 if let Some(patterns) = &claim.patterns {
267 let resource_patterns: Vec<_> = patterns
268 .iter()
269 .filter(|p| !p.starts_with("agent://"))
270 .collect();
271 for pattern in resource_patterns {
272 let expires = claim
273 .expires_in_secs
274 .map(|s| format!("({}m left)", s / 60))
275 .unwrap_or_default();
276 println!(" {} {}", pattern, expires);
277 }
278 }
279 }
280 } else {
281 println!(" {}No resource claims{}", c.dim, c.reset);
282 }
283 } else {
284 println!(" {}No active claims{}", c.dim, c.reset);
285 }
286 } else {
287 println!(" {}No active claims{}", c.dim, c.reset);
288 }
289 } else {
290 println!(" {}No active claims{}", c.dim, c.reset);
291 }
292 println!();
293
294 if unread_count > 0 {
296 println!(
297 "{}",
298 hint(
299 &c,
300 &format!(
301 "Get unread messages and mark them as read: bus inbox --agent {} --channels {} --mark-read",
302 agent, project
303 )
304 )
305 );
306 } else if has_reviews {
307 println!(
308 "{}",
309 hint(
310 &c,
311 &format!(
312 "Start review: maw exec default -- crit inbox --agent {}",
313 agent
314 )
315 )
316 );
317 } else {
318 println!("{}", hint(&c, "No work pending"));
319 }
320
321 Ok(())
322}