1use std::io::{BufRead, BufReader, Read, Write};
8use std::sync::Mutex;
9
10use crate::interaction::{
11 ChannelCapabilities, Decision, InteractionKind, InteractionRequest, InteractionResponse,
12 Notification, NotificationLevel,
13};
14use crate::review_channel::{ReviewChannel, ReviewChannelError};
15use crate::session_channel::{HumanInput, SessionChannel, SessionChannelError, SessionEvent};
16
17pub struct TerminalChannel {
22 reader: Mutex<BufReader<Box<dyn Read + Send>>>,
23 writer: Mutex<Box<dyn Write + Send>>,
24 channel_id: String,
25}
26
27impl TerminalChannel {
28 pub fn new(
31 reader: Box<dyn Read + Send>,
32 writer: Box<dyn Write + Send>,
33 channel_id: impl Into<String>,
34 ) -> Self {
35 Self {
36 reader: Mutex::new(BufReader::new(reader)),
37 writer: Mutex::new(writer),
38 channel_id: channel_id.into(),
39 }
40 }
41
42 pub fn stdio() -> Self {
44 Self::new(
45 Box::new(std::io::stdin()),
46 Box::new(std::io::stdout()),
47 "terminal:stdio",
48 )
49 }
50
51 fn render_request(&self, request: &InteractionRequest) -> String {
53 let mut out = String::new();
54 out.push('\n');
55 out.push_str(&"=".repeat(60));
56 out.push('\n');
57
58 match &request.kind {
59 InteractionKind::DraftReview => {
60 out.push_str(" DRAFT REVIEW REQUIRED\n");
61 out.push_str(&"-".repeat(60));
62 out.push('\n');
63 if let Some(summary) = request.context.get("summary").and_then(|v| v.as_str()) {
64 out.push_str(&format!(" Summary: {}\n", summary));
65 }
66 if let Some(count) = request
67 .context
68 .get("artifact_count")
69 .and_then(|v| v.as_u64())
70 {
71 out.push_str(&format!(" Artifacts: {}\n", count));
72 }
73 if let Some(draft_id) = request.context.get("draft_id").and_then(|v| v.as_str()) {
74 out.push_str(&format!(" Draft ID: {}\n", draft_id));
75 }
76 }
77 InteractionKind::PlanNegotiation => {
78 out.push_str(" PLAN UPDATE PROPOSED\n");
79 out.push_str(&"-".repeat(60));
80 out.push('\n');
81 if let Some(phase) = request.context.get("phase").and_then(|v| v.as_str()) {
82 out.push_str(&format!(" Phase: {}\n", phase));
83 }
84 if let Some(status) = request
85 .context
86 .get("proposed_status")
87 .and_then(|v| v.as_str())
88 {
89 out.push_str(&format!(" Proposed status: {}\n", status));
90 }
91 }
92 InteractionKind::ApprovalDiscussion => {
93 out.push_str(" APPROVAL REQUIRED\n");
94 out.push_str(&"-".repeat(60));
95 out.push('\n');
96 if let Some(msg) = request.context.as_str() {
97 out.push_str(&format!(" {}\n", msg));
98 }
99 }
100 InteractionKind::Escalation => {
101 out.push_str(" ESCALATION\n");
102 out.push_str(&"-".repeat(60));
103 out.push('\n');
104 if let Some(reason) = request.context.get("reason").and_then(|v| v.as_str()) {
105 out.push_str(&format!(" Reason: {}\n", reason));
106 }
107 }
108 InteractionKind::AgentQuestion => {
109 out.push_str(" AGENT QUESTION\n");
110 out.push_str(&"-".repeat(60));
111 out.push('\n');
112 if let Some(q) = request.context.get("question").and_then(|v| v.as_str()) {
113 out.push_str(&format!(" Question: {}\n", q));
114 }
115 if let Some(ctx) = request.context.get("context").and_then(|v| v.as_str()) {
116 out.push_str(&format!(" Context: {}\n", ctx));
117 }
118 if let Some(hint) = request
119 .context
120 .get("response_hint")
121 .and_then(|v| v.as_str())
122 {
123 out.push_str(&format!(" Expected response: {}\n", hint));
124 }
125 }
126 InteractionKind::Custom(name) => {
127 out.push_str(&format!(" INTERACTION: {}\n", name.to_uppercase()));
128 out.push_str(&"-".repeat(60));
129 out.push('\n');
130 }
131 }
132
133 out.push_str(&"-".repeat(60));
134 out.push('\n');
135 out.push_str(" [a]pprove [r]eject [d]iscuss [s]kip\n");
136 out.push_str(&"=".repeat(60));
137 out.push_str("\n> ");
138 out
139 }
140
141 fn parse_decision(input: &str) -> Result<Decision, ReviewChannelError> {
143 let trimmed = input.trim().to_lowercase();
144 match trimmed.as_str() {
145 "a" | "approve" | "y" | "yes" => Ok(Decision::Approve),
146 "d" | "discuss" => Ok(Decision::Discuss),
147 "s" | "skip" => Ok(Decision::SkipForNow),
148 _ if trimmed.starts_with("r") || trimmed.starts_with("n") => {
149 let reason = if trimmed.len() > 1 {
151 let rest = trimmed
153 .trim_start_matches("reject")
154 .trim_start_matches("no")
155 .trim_start_matches('r')
156 .trim_start_matches('n')
157 .trim_start_matches(':')
158 .trim();
159 if rest.is_empty() {
160 "rejected by reviewer".to_string()
161 } else {
162 rest.to_string()
163 }
164 } else {
165 "rejected by reviewer".to_string()
166 };
167 Ok(Decision::Reject { reason })
168 }
169 "" => Err(ReviewChannelError::InvalidResponse("empty response".into())),
170 _ => Err(ReviewChannelError::InvalidResponse(format!(
171 "unrecognized input: '{}'",
172 trimmed
173 ))),
174 }
175 }
176
177 fn render_notification(notification: &Notification) -> String {
179 let prefix = match notification.level {
180 NotificationLevel::Debug => "[DEBUG]",
181 NotificationLevel::Info => "[INFO]",
182 NotificationLevel::Warning => "[WARN]",
183 NotificationLevel::Error => "[ERROR]",
184 };
185 format!("{} {}\n", prefix, notification.message)
186 }
187}
188
189impl ReviewChannel for TerminalChannel {
190 fn request_interaction(
191 &self,
192 request: &InteractionRequest,
193 ) -> Result<InteractionResponse, ReviewChannelError> {
194 let rendered = self.render_request(request);
195
196 {
198 let mut writer = self
199 .writer
200 .lock()
201 .map_err(|e| ReviewChannelError::Other(format!("writer lock poisoned: {}", e)))?;
202 writer.write_all(rendered.as_bytes())?;
203 writer.flush()?;
204 }
205
206 let mut line = String::new();
208 {
209 let mut reader = self
210 .reader
211 .lock()
212 .map_err(|e| ReviewChannelError::Other(format!("reader lock poisoned: {}", e)))?;
213 let bytes = reader.read_line(&mut line)?;
214 if bytes == 0 {
215 return Err(ReviewChannelError::ChannelClosed);
216 }
217 }
218
219 let decision = Self::parse_decision(&line)?;
220
221 Ok(InteractionResponse::new(request.interaction_id, decision)
222 .with_responder(&self.channel_id))
223 }
224
225 fn notify(&self, notification: &Notification) -> Result<(), ReviewChannelError> {
226 let rendered = Self::render_notification(notification);
227 let mut writer = self
228 .writer
229 .lock()
230 .map_err(|e| ReviewChannelError::Other(format!("writer lock poisoned: {}", e)))?;
231 writer.write_all(rendered.as_bytes())?;
232 writer.flush()?;
233 Ok(())
234 }
235
236 fn capabilities(&self) -> ChannelCapabilities {
237 ChannelCapabilities {
238 supports_async: false,
239 supports_rich_media: false,
240 supports_threads: false,
241 }
242 }
243
244 fn channel_id(&self) -> &str {
245 &self.channel_id
246 }
247}
248
249pub struct AutoApproveChannel {
252 channel_id: String,
253}
254
255impl AutoApproveChannel {
256 pub fn new() -> Self {
257 Self {
258 channel_id: "auto-approve".to_string(),
259 }
260 }
261}
262
263impl Default for AutoApproveChannel {
264 fn default() -> Self {
265 Self::new()
266 }
267}
268
269pub struct TerminalSessionChannel {
273 channel_id: String,
274}
275
276impl TerminalSessionChannel {
277 pub fn new() -> Self {
278 Self {
279 channel_id: "terminal:session".to_string(),
280 }
281 }
282}
283
284impl Default for TerminalSessionChannel {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290impl SessionChannel for TerminalSessionChannel {
291 fn emit(&self, event: &SessionEvent) -> Result<(), SessionChannelError> {
292 println!("{}", event);
293 Ok(())
294 }
295
296 fn receive(
297 &self,
298 timeout: std::time::Duration,
299 ) -> Result<Option<HumanInput>, SessionChannelError> {
300 let _ = timeout;
304 let mut line = String::new();
305 match std::io::stdin().read_line(&mut line) {
306 Ok(0) => Ok(None), Ok(_) => {
308 let trimmed = line.trim();
309 if trimmed.is_empty() {
310 Ok(None)
311 } else if trimmed.eq_ignore_ascii_case("abort") {
312 Ok(Some(HumanInput::Abort))
313 } else {
314 Ok(Some(HumanInput::Message {
315 text: trimmed.to_string(),
316 }))
317 }
318 }
319 Err(e) => Err(SessionChannelError::Io(e)),
320 }
321 }
322
323 fn channel_id(&self) -> &str {
324 &self.channel_id
325 }
326}
327
328impl ReviewChannel for AutoApproveChannel {
329 fn request_interaction(
330 &self,
331 request: &InteractionRequest,
332 ) -> Result<InteractionResponse, ReviewChannelError> {
333 Ok(
334 InteractionResponse::new(request.interaction_id, Decision::Approve)
335 .with_responder(&self.channel_id),
336 )
337 }
338
339 fn notify(&self, _notification: &Notification) -> Result<(), ReviewChannelError> {
340 Ok(())
341 }
342
343 fn capabilities(&self) -> ChannelCapabilities {
344 ChannelCapabilities::default()
345 }
346
347 fn channel_id(&self) -> &str {
348 &self.channel_id
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::interaction::Notification;
356 use std::io::Cursor;
357 use uuid::Uuid;
358
359 fn mock_channel(input: &str) -> (TerminalChannel, std::sync::Arc<Mutex<Vec<u8>>>) {
360 let output_buf = std::sync::Arc::new(Mutex::new(Vec::new()));
361 let output_writer = output_buf.clone();
362
363 struct SharedWriter(std::sync::Arc<Mutex<Vec<u8>>>);
364 impl Write for SharedWriter {
365 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
366 self.0.lock().unwrap().write(buf)
367 }
368 fn flush(&mut self) -> std::io::Result<()> {
369 Ok(())
370 }
371 }
372
373 let reader = Box::new(Cursor::new(input.as_bytes().to_vec()));
374 let writer = Box::new(SharedWriter(output_writer));
375 let channel = TerminalChannel::new(reader, writer, "test:mock");
376 (channel, output_buf)
377 }
378
379 #[test]
380 fn approve_draft_review() {
381 let (channel, _output) = mock_channel("a\n");
382 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Test draft", 3);
383 let resp = channel.request_interaction(&req).unwrap();
384 assert_eq!(resp.decision, Decision::Approve);
385 assert_eq!(resp.interaction_id, req.interaction_id);
386 assert_eq!(resp.responder_id.as_deref(), Some("test:mock"));
387 }
388
389 #[test]
390 fn reject_with_reason() {
391 let (channel, _output) = mock_channel("reject: needs more tests\n");
392 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
393 let resp = channel.request_interaction(&req).unwrap();
394 assert_eq!(
395 resp.decision,
396 Decision::Reject {
397 reason: "needs more tests".into()
398 }
399 );
400 }
401
402 #[test]
403 fn reject_shorthand() {
404 let (channel, _output) = mock_channel("r\n");
405 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
406 let resp = channel.request_interaction(&req).unwrap();
407 assert!(matches!(resp.decision, Decision::Reject { .. }));
408 }
409
410 #[test]
411 fn discuss_response() {
412 let (channel, _output) = mock_channel("d\n");
413 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
414 let resp = channel.request_interaction(&req).unwrap();
415 assert_eq!(resp.decision, Decision::Discuss);
416 }
417
418 #[test]
419 fn skip_response() {
420 let (channel, _output) = mock_channel("s\n");
421 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
422 let resp = channel.request_interaction(&req).unwrap();
423 assert_eq!(resp.decision, Decision::SkipForNow);
424 }
425
426 #[test]
427 fn yes_is_approve() {
428 let (channel, _output) = mock_channel("yes\n");
429 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
430 let resp = channel.request_interaction(&req).unwrap();
431 assert_eq!(resp.decision, Decision::Approve);
432 }
433
434 #[test]
435 fn empty_input_is_error() {
436 let (channel, _output) = mock_channel("\n");
437 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
438 let result = channel.request_interaction(&req);
439 assert!(matches!(
440 result,
441 Err(ReviewChannelError::InvalidResponse(_))
442 ));
443 }
444
445 #[test]
446 fn eof_is_channel_closed() {
447 let (channel, _output) = mock_channel("");
448 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Draft", 1);
449 let result = channel.request_interaction(&req);
450 assert!(matches!(result, Err(ReviewChannelError::ChannelClosed)));
451 }
452
453 #[test]
454 fn renders_draft_review_output() {
455 let (channel, output) = mock_channel("a\n");
456 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Add auth module", 5);
457 channel.request_interaction(&req).unwrap();
458
459 let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
460 assert!(rendered.contains("DRAFT REVIEW REQUIRED"));
461 assert!(rendered.contains("Add auth module"));
462 assert!(rendered.contains("Artifacts: 5"));
463 assert!(rendered.contains("[a]pprove"));
464 }
465
466 #[test]
467 fn renders_plan_negotiation() {
468 let (channel, output) = mock_channel("a\n");
469 let req = InteractionRequest::plan_negotiation("v0.4.2", "done");
470 channel.request_interaction(&req).unwrap();
471
472 let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
473 assert!(rendered.contains("PLAN UPDATE PROPOSED"));
474 assert!(rendered.contains("v0.4.2"));
475 }
476
477 #[test]
478 fn notify_renders_to_output() {
479 let (channel, output) = mock_channel("");
480 let notif = Notification::info("Sub-goal 2 of 5 complete");
481 channel.notify(¬if).unwrap();
482
483 let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
484 assert!(rendered.contains("[INFO]"));
485 assert!(rendered.contains("Sub-goal 2 of 5 complete"));
486 }
487
488 #[test]
489 fn notify_warning_prefix() {
490 let (channel, output) = mock_channel("");
491 let notif = Notification::warning("Agent approaching token limit");
492 channel.notify(¬if).unwrap();
493
494 let rendered = String::from_utf8(output.lock().unwrap().clone()).unwrap();
495 assert!(rendered.contains("[WARN]"));
496 }
497
498 #[test]
499 fn channel_capabilities() {
500 let (channel, _) = mock_channel("");
501 let caps = channel.capabilities();
502 assert!(!caps.supports_async);
503 assert!(!caps.supports_rich_media);
504 assert!(!caps.supports_threads);
505 }
506
507 #[test]
508 fn channel_id_returns_configured_id() {
509 let (channel, _) = mock_channel("");
510 assert_eq!(channel.channel_id(), "test:mock");
511 }
512
513 #[test]
514 fn auto_approve_channel_approves_all() {
515 let channel = AutoApproveChannel::new();
516 let req = InteractionRequest::draft_review(Uuid::new_v4(), "Any draft", 10);
517 let resp = channel.request_interaction(&req).unwrap();
518 assert_eq!(resp.decision, Decision::Approve);
519 assert_eq!(resp.responder_id.as_deref(), Some("auto-approve"));
520 }
521
522 #[test]
523 fn auto_approve_channel_notify_is_noop() {
524 let channel = AutoApproveChannel::new();
525 let notif = Notification::info("test");
526 assert!(channel.notify(¬if).is_ok());
527 }
528
529 #[test]
530 fn parse_decision_variants() {
531 assert_eq!(
532 TerminalChannel::parse_decision("approve").unwrap(),
533 Decision::Approve
534 );
535 assert_eq!(
536 TerminalChannel::parse_decision("y").unwrap(),
537 Decision::Approve
538 );
539 assert_eq!(
540 TerminalChannel::parse_decision("discuss").unwrap(),
541 Decision::Discuss
542 );
543 assert_eq!(
544 TerminalChannel::parse_decision("skip").unwrap(),
545 Decision::SkipForNow
546 );
547 assert!(TerminalChannel::parse_decision("unknown").is_err());
548 }
549}