ort_openrouter_cli/output/
writer.rs

1//! ort: Open Router CLI
2//! https://github.com/grahamking/ort
3//!
4//! MIT License
5//! Copyright (c) 2025 Graham King
6
7extern crate alloc;
8use core::ffi::c_void;
9
10use alloc::ffi::CString;
11use alloc::format;
12use alloc::string::{String, ToString};
13use alloc::vec::Vec;
14
15use crate::{
16    ErrorKind, LastData, Message, OrtResult, PromptOpts, Response, ThinkEvent, Write,
17    common::config, common::file, common::queue, common::stats, common::utils, ort_from_err,
18};
19use crate::{libc, ort_error};
20
21const CURSOR_ON: &[u8] = "\x1b[?25h".as_bytes();
22
23//const CURSOR_OFF: &str = "\x1b[?25l";
24const MSG_CONNECTING: &[u8] = "\x1b[?25lConnecting...\r".as_bytes();
25
26// \r{CLEAR_LINE}\n
27const MSG_CLEAR_LINE: &[u8] = "\r\x1b[2K\n".as_bytes();
28
29// These are all surrounded by BOLD_START and BOLD_END, but I can't find a way to
30// do string concatenation at build time with constants
31//const BOLD_START: &str = "\x1b[1m";
32//const BOLD_END: &str = "\x1b[0m";
33const MSG_PROCESSING: &[u8] = "\x1b[1mProcessing...\x1b[0m\r".as_bytes();
34const MSG_THINK_TAG_END: &[u8] = "\x1b[1m</think>\x1b[0m\n".as_bytes();
35const MSG_THINKING: &[u8] = "\x1b[1mThinking...\x1b[0m  ".as_bytes();
36const MSG_THINK_TAG_START: &[u8] = "\x1b[1m<think>\x1b[0m".as_bytes();
37
38// The spinner displays a sequence of these characters: | / - \ , which when
39// animated look like they are spinning.
40// The array includes the ANSI escape to move back one character after each one
41// is printed, so they overwrite each other.
42//const BACK_ONE: &[u8] = "\x1b[1D".as_bytes();
43const SPINNER: [&[u8]; 4] = [
44    "|\x1b[1D".as_bytes(),
45    "/\x1b[1D".as_bytes(),
46    "-\x1b[1D".as_bytes(),
47    "\\\x1b[1D".as_bytes(),
48];
49
50const ERR_RATE_LIMITED: &str = "429 Too Many Requests";
51
52pub struct ConsoleWriter<W: Write + Send> {
53    pub writer: W, // Must handle ANSI control chars
54    pub show_reasoning: bool,
55}
56
57impl<W: Write + Send> ConsoleWriter<W> {
58    pub fn into_inner(self) -> W {
59        self.writer
60    }
61    pub fn run<const N: usize>(
62        &mut self,
63        mut rx: queue::Consumer<Response, N>,
64    ) -> OrtResult<stats::Stats> {
65        let _ = self.writer.write(MSG_CONNECTING);
66        let _ = self.writer.flush();
67
68        let mut is_first_content = true;
69        let mut spindx = 0;
70        let mut stats_out = None;
71        while let Some(data) = rx.get_next() {
72            match data {
73                Response::Start => {
74                    let _ = self.writer.write(MSG_PROCESSING);
75                    let _ = self.writer.flush();
76                }
77                Response::Think(think) => {
78                    if self.show_reasoning {
79                        match think {
80                            ThinkEvent::Start => {
81                                let _ = self.writer.write(MSG_THINK_TAG_START);
82                            }
83                            ThinkEvent::Content(s) => {
84                                let _ = self.writer.write_all(s.as_bytes());
85                                let _ = self.writer.flush();
86                            }
87                            ThinkEvent::Stop => {
88                                let _ = self.writer.write(MSG_THINK_TAG_END);
89                            }
90                        }
91                    } else {
92                        match think {
93                            ThinkEvent::Start => {
94                                let _ = self.writer.write(MSG_THINKING);
95                                let _ = self.writer.flush();
96                            }
97                            ThinkEvent::Content(_) => {
98                                let _ = self.writer.write(SPINNER[spindx % SPINNER.len()]);
99                                let _ = self.writer.flush();
100                                spindx += 1;
101                            }
102                            ThinkEvent::Stop => {}
103                        }
104                    }
105                }
106                Response::Content(content) => {
107                    if is_first_content {
108                        // Erase the Processing or Thinking line
109                        let _ = self.writer.write(MSG_CLEAR_LINE);
110                        is_first_content = false;
111                    }
112                    let _ = self.writer.write_all(content.as_bytes());
113                    let _ = self.writer.flush();
114                }
115                Response::Stats(stats) => {
116                    stats_out = Some(stats);
117                }
118                Response::Error(err) => {
119                    let _ = self.writer.write(CURSOR_ON);
120                    let _ = self.writer.flush();
121                    if err.to_string().contains(ERR_RATE_LIMITED) {
122                        return Err(ort_error(ErrorKind::RateLimited, ""));
123                    } else {
124                        return Err(ort_from_err(
125                            ErrorKind::ResponseStreamError,
126                            "OpenRouter said no",
127                            err,
128                        ));
129                    }
130                }
131                Response::None => {
132                    panic!("Response::None means we read the wrong Queue position");
133                }
134            }
135        }
136
137        let _ = self.writer.write(CURSOR_ON);
138        let _ = self.writer.flush();
139
140        let Some(stats) = stats_out else {
141            return Err(ort_error(ErrorKind::MissingUsageStats, ""));
142        };
143        Ok(stats)
144    }
145}
146
147pub struct FileWriter<W: Write + Send> {
148    pub writer: W,
149    pub show_reasoning: bool,
150}
151
152impl<W: Write + Send> FileWriter<W> {
153    pub fn into_inner(self) -> W {
154        self.writer
155    }
156    pub fn run<const N: usize>(
157        &mut self,
158        mut rx: queue::Consumer<Response, N>,
159    ) -> OrtResult<stats::Stats> {
160        let mut stats_out = None;
161        while let Some(data) = rx.get_next() {
162            match data {
163                Response::Start => {}
164                Response::Think(think) => {
165                    if self.show_reasoning {
166                        match think {
167                            ThinkEvent::Start => {
168                                let _ = self.writer.write("<think>".as_bytes());
169                            }
170                            ThinkEvent::Content(s) => {
171                                let _ = self.writer.write_all(s.as_bytes());
172                            }
173                            ThinkEvent::Stop => {
174                                let _ = self.writer.write("</think>\n\n".as_bytes());
175                            }
176                        }
177                    }
178                }
179                Response::Content(content) => {
180                    let _ = self.writer.write_all(content.as_bytes());
181                }
182                Response::Stats(stats) => {
183                    stats_out = Some(stats);
184                }
185                Response::Error(err) => {
186                    if err.to_string().contains(ERR_RATE_LIMITED) {
187                        return Err(ort_error(ErrorKind::RateLimited, ""));
188                    } else {
189                        return Err(ort_from_err(
190                            ErrorKind::ResponseStreamError,
191                            "OpenRouter said no",
192                            err,
193                        ));
194                    }
195                }
196                Response::None => {
197                    return Err(ort_error(
198                        ErrorKind::QueueDesync,
199                        "Response::None means we read the wrong Queue position",
200                    ));
201                }
202            }
203        }
204
205        let Some(stats) = stats_out else {
206            return Err(ort_error(ErrorKind::MissingUsageStats, ""));
207        };
208        Ok(stats)
209    }
210}
211
212pub struct CollectedWriter {}
213
214impl CollectedWriter {
215    pub fn run<const N: usize>(
216        &mut self,
217        mut rx: queue::Consumer<Response, N>,
218    ) -> OrtResult<String> {
219        let mut got_stats = None;
220        let mut contents = Vec::with_capacity(1024);
221        while let Some(data) = rx.get_next() {
222            match data {
223                Response::Start => {}
224                Response::Think(_) => {}
225                Response::Content(content) => {
226                    contents.push(content);
227                }
228                Response::Stats(stats) => {
229                    got_stats = Some(stats);
230                }
231                Response::Error(_err) => {
232                    // Original message: CollectedWriter + err detail
233                    return Err(ort_error(
234                        ErrorKind::ResponseStreamError,
235                        "CollectedWriter response error",
236                    ));
237                }
238                Response::None => {
239                    return Err(ort_error(
240                        ErrorKind::QueueDesync,
241                        "Response::None means we read the wrong Queue position",
242                    ));
243                }
244            }
245        }
246
247        let out =
248            "--- ".to_string() + &got_stats.unwrap().to_string() + " ---\n" + &contents.join("");
249        //let out = format!("--- {} ---\n{}", got_stats.unwrap(), contents.join(""));
250        Ok(out)
251    }
252}
253
254pub struct LastWriter {
255    w: file::File,
256    data: LastData,
257}
258
259impl LastWriter {
260    pub fn new(opts: PromptOpts, messages: Vec<Message>) -> OrtResult<Self> {
261        let last_filename = format!("last-{}.json", utils::tmux_pane_id());
262        let mut last_path = config::cache_dir()?;
263        last_path.push('/');
264        last_path.push_str(&last_filename);
265        let c_path = CString::new(last_path)
266            .map_err(|e| ort_from_err(ErrorKind::FileCreateFailed, "CString::new last path", e))?;
267        let last_file = unsafe {
268            file::File::create(c_path.as_ptr())
269                .map_err(|e| ort_from_err(ErrorKind::FileCreateFailed, "create last file", e))?
270        };
271        let data = LastData { opts, messages };
272        Ok(LastWriter { data, w: last_file })
273    }
274
275    pub fn run<const N: usize>(
276        &mut self,
277        mut rx: queue::Consumer<Response, N>,
278    ) -> OrtResult<stats::Stats> {
279        let mut contents = Vec::with_capacity(1024);
280        while let Some(data) = rx.get_next() {
281            match data {
282                Response::Start => {}
283                Response::Think(_) => {}
284                Response::Content(content) => {
285                    contents.push(content);
286                }
287                Response::Stats(stats) => {
288                    self.data.opts.provider = Some(utils::slug(stats.provider()));
289                }
290                Response::Error(_err) => {
291                    // Original: format!(\"LastWriter: {err}\")
292                    return Err(ort_error(
293                        ErrorKind::LastWriterError,
294                        "LastWriter run error",
295                    ));
296                }
297                Response::None => {
298                    return Err(ort_error(
299                        ErrorKind::QueueDesync,
300                        "Response::None means we read the wrong Queue position",
301                    ));
302                }
303            }
304        }
305
306        let message = Message::assistant(contents.join(""));
307        self.data.messages.push(message);
308
309        self.data.to_json_writer(&mut self.w)?;
310        let _ = (&mut self.w).flush();
311
312        Ok(stats::Stats::default()) // Stats is not used
313    }
314}
315
316pub struct StdoutWriter {}
317
318impl Write for StdoutWriter {
319    fn write(&mut self, buf: &[u8]) -> OrtResult<usize> {
320        let bytes_written = unsafe { libc::write(1, buf.as_ptr() as *const c_void, buf.len()) };
321        if bytes_written >= 0 {
322            Ok(bytes_written as usize)
323        } else {
324            Err(ort_error(ErrorKind::StdoutWriteFailed, ""))
325        }
326    }
327
328    fn flush(&mut self) -> OrtResult<()> {
329        Ok(())
330    }
331}