I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

1 //! OSC 7501 reports, owned exclusively by the interactive terminal surface.
2 use std::io::Write;
3
4 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
5 pub(crate) enum Status {
6 Idle,
7 Working,
8 Done,
9 Error,
10 Permission,
11 Question,
12 Auth,
13 }
14
15 impl Status {
16 fn pairs(self) -> &'static str {
17 match self {
18 Self::Idle => "state=idle",
19 Self::Working => "state=working",
20 Self::Done => "state=done",
21 Self::Error => "state=error",
22 Self::Permission => "state=blocked:kind=permission",
23 Self::Question => "state=blocked:kind=question",
24 Self::Auth => "state=blocked:kind=auth",
25 }
26 }
27 }
28
29 pub(crate) struct Reporter {
30 writer: Box<dyn Write + Send>,
31 last: Option<Status>,
32 }
33
34 impl Reporter {
35 pub(crate) fn new(writer: impl Write + Send + 'static) -> Self {
36 Self {
37 writer: Box::new(writer),
38 last: None,
39 }
40 }
41
42 pub(crate) fn report(&mut self, status: Status) {
43 if self.last == Some(status) {
44 return;
45 }
46 // Status is advisory: a terminal write failure must not stop the agent.
47 if write!(self.writer, "\x1b]7501;{}:app=sigit\x1b\\", status.pairs())
48 .and_then(|()| self.writer.flush())
49 .is_ok()
50 {
51 self.last = Some(status);
52 }
53 }
54 }
55
56 // Do not clear the root record on drop: a bare clear removes every program's
57 // records. Terminals expire working/blocked on process exit and retain done/error.
58
59 #[cfg(test)]
60 mod tests {
61 use super::*;
62 use std::sync::{Arc, Mutex};
63
64 #[derive(Clone, Default)]
65 struct Output(Arc<Mutex<Vec<u8>>>);
66 impl Write for Output {
67 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
68 self.0.lock().unwrap().extend_from_slice(bytes);
69 Ok(bytes.len())
70 }
71 fn flush(&mut self) -> std::io::Result<()> {
72 Ok(())
73 }
74 }
75
76 #[test]
77 fn lifecycle_reports_are_framed_and_deduplicated() {
78 let output = Output::default();
79 {
80 let mut reporter = Reporter::new(output.clone());
81 for status in [
82 Status::Idle,
83 Status::Working,
84 Status::Working,
85 Status::Permission,
86 Status::Working,
87 Status::Question,
88 Status::Auth,
89 Status::Done,
90 Status::Error,
91 Status::Idle,
92 ] {
93 reporter.report(status);
94 }
95 }
96 let actual = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
97 let expected = [
98 "state=idle",
99 "state=working",
100 "state=blocked:kind=permission",
101 "state=working",
102 "state=blocked:kind=question",
103 "state=blocked:kind=auth",
104 "state=done",
105 "state=error",
106 "state=idle",
107 ]
108 .map(|pairs| format!("\x1b]7501;{pairs}:app=sigit\x1b\\"))
109 .join("");
110 assert_eq!(actual, expected);
111 }
112
113 #[test]
114 fn unwinding_does_not_clear_terminal_records() {
115 let output = Output::default();
116 let captured = output.clone();
117 let _ = std::panic::catch_unwind(move || {
118 let mut reporter = Reporter::new(captured);
119 reporter.report(Status::Working);
120 panic!("terminal loop failed");
121 });
122 assert_eq!(
123 *output.0.lock().unwrap(),
124 b"\x1b]7501;state=working:app=sigit\x1b\\"
125 );
126 }
127
128 #[test]
129 fn dropping_preserves_completion_and_error_reports() {
130 for status in [Status::Done, Status::Error] {
131 let output = Output::default();
132 {
133 let mut reporter = Reporter::new(output.clone());
134 reporter.report(status);
135 }
136 assert_eq!(
137 *output.0.lock().unwrap(),
138 format!("\x1b]7501;{}:app=sigit\x1b\\", status.pairs()).as_bytes()
139 );
140 }
141 }
142
143 #[test]
144 fn unused_reporter_emits_nothing() {
145 let output = Output::default();
146 drop(Reporter::new(output.clone()));
147 assert!(output.0.lock().unwrap().is_empty());
148 }
149 }