• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 21056773217

16 Jan 2026 05:32AM UTC coverage: 51.892% (+0.3%) from 51.585%
21056773217

Pull #376

github

web-flow
Merge 3b1e362a1 into c960da9f8
Pull Request #376: refactor(cli, llm, inquire): Decouple output handling from stdout

170 of 333 new or added lines in 14 files covered. (51.05%)

3 existing lines in 3 files now uncovered.

9916 of 19109 relevant lines covered (51.89%)

122.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/jp_cli/src/cmd/query/response_handler.rs
1
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
2

3
use crossterm::style::{Color, Stylize as _};
4
use jp_config::style::{LinkStyle, StyleConfig};
5
use jp_printer::{PrintableExt as _, Printer};
6
use jp_term::{code, osc::hyperlink};
7
use termimad::FmtText;
8

9
use super::{Line, LineVariant, RenderMode};
10
use crate::Error;
11

12
#[derive(Debug)]
13
pub(super) struct ResponseHandler {
14
    /// How to render the response.
15
    pub render_mode: RenderMode,
16

17
    /// Whether to render tool call request/results.
18
    pub render_tool_calls: bool,
19

20
    /// The streamed, unprocessed lines received from the LLM.
21
    received: Vec<String>,
22

23
    pub printer: Arc<Printer>,
24

25
    /// The lines that have been parsed so far.
26
    ///
27
    /// If `should_stream` is `true`, these lines have been printed to the
28
    /// terminal. Otherwise they will be printed when the response handler is
29
    /// finished.
30
    pub parsed: Vec<String>,
31

32
    /// A temporary buffer of data received from the LLM.
33
    pub buffer: String,
34

35
    in_fenced_code_block: bool,
36
    // (language, code)
37
    code_buffer: (Option<String>, Vec<String>),
38
    code_line: usize,
39

40
    // The last index of the line that ends a code block.
41
    // (streamed, printed)
42
    last_fenced_code_block_end: (usize, usize),
43
}
44

45
impl ResponseHandler {
NEW
46
    pub fn new(render_mode: RenderMode, render_tool_calls: bool, printer: Arc<Printer>) -> Self {
×
47
        Self {
×
48
            render_mode,
×
49
            render_tool_calls,
×
NEW
50
            printer,
×
NEW
51
            received: vec![],
×
NEW
52
            parsed: vec![],
×
NEW
53
            buffer: String::new(),
×
NEW
54
            in_fenced_code_block: false,
×
NEW
55
            code_buffer: (None, vec![]),
×
NEW
56
            code_line: 0,
×
NEW
57
            last_fenced_code_block_end: (0, 0),
×
58
        }
×
59
    }
×
60

61
    pub fn drain(&mut self, style: &StyleConfig, raw: bool) -> Result<(), Error> {
×
62
        if self.buffer.is_empty() {
×
63
            return Ok(());
×
64
        }
×
65

66
        let line = Line::new(
×
67
            self.buffer.drain(..).collect(),
×
68
            self.in_fenced_code_block,
×
69
            raw,
×
70
        );
71

72
        self.handle_inner(line, style)
×
73
    }
×
74

75
    fn handle_inner(&mut self, line: Line, style: &StyleConfig) -> Result<(), Error> {
×
76
        let Line { content, variant } = line;
×
77
        self.received.push(content);
×
78

79
        let delay = match variant {
×
80
            LineVariant::Code => style.typewriter.code_delay.into(),
×
NEW
81
            LineVariant::Normal => style.typewriter.text_delay.into(),
×
NEW
82
            _ => Duration::ZERO,
×
83
        };
84

85
        let lines = self.handle_line(&variant, style)?;
×
86
        if !matches!(self.render_mode, RenderMode::Buffered) {
×
NEW
87
            self.printer.print(lines.join("\n").typewriter(delay));
×
88
        }
×
89

90
        self.parsed.extend(lines);
×
91

92
        Ok(())
×
93
    }
×
94

95
    #[expect(clippy::too_many_lines)]
96
    fn handle_line(
×
97
        &mut self,
×
98
        variant: &LineVariant,
×
99
        style: &StyleConfig,
×
100
    ) -> Result<Vec<String>, Error> {
×
101
        let Some(content) = self.received.last().map(String::as_str) else {
×
102
            return Ok(vec![]);
×
103
        };
104

105
        match variant {
×
106
            LineVariant::Raw => Ok(content.lines().map(str::to_owned).collect()),
×
107
            LineVariant::Code => {
108
                self.code_line += 1;
×
109
                self.code_buffer.1.push(content.to_owned());
×
110

111
                let mut buf = String::new();
×
112
                let config = code::Config {
×
113
                    language: self.code_buffer.0.clone(),
×
114
                    theme: style.code.color.then(|| style.code.theme.clone()),
×
115
                };
116

117
                if !code::format(content, &mut buf, &config)? {
×
118
                    let config = code::Config {
×
119
                        language: None,
×
120
                        theme: config.theme,
×
121
                    };
×
122

123
                    code::format(content, &mut buf, &config)?;
×
124
                }
×
125

126
                if style.code.line_numbers {
×
127
                    buf.insert_str(
×
128
                        0,
×
129
                        &format!("{:2} │ ", self.code_line)
×
130
                            .with(Color::AnsiValue(238))
×
131
                            .to_string(),
×
132
                    );
×
133
                }
×
134

135
                Ok(vec![buf])
×
136
            }
137
            LineVariant::FencedCodeBlockStart { language } => {
×
138
                self.code_buffer.0.clone_from(language);
×
139
                self.code_buffer.1.clear();
×
140
                self.code_line = 0;
×
141
                self.in_fenced_code_block = true;
×
142

143
                Ok(vec![content.with(Color::AnsiValue(238)).to_string()])
×
144
            }
145
            LineVariant::FencedCodeBlockEnd { indent } => {
×
146
                self.last_fenced_code_block_end = (self.received.len(), self.parsed.len() + 2);
×
147

148
                let path = self.persist_code_block()?;
×
149
                let mut links = vec![];
×
150

151
                match style.code.file_link {
×
152
                    LinkStyle::Off => {}
×
153
                    LinkStyle::Full => {
×
154
                        links.push(format!("{}see: {}", " ".repeat(*indent), path.display()));
×
155
                    }
×
156
                    LinkStyle::Osc8 => {
×
157
                        links.push(format!(
×
158
                            "{}[{}]",
×
159
                            " ".repeat(*indent),
×
160
                            hyperlink(
×
161
                                format!("file://{}", path.display()),
×
162
                                "open in editor".red().to_string()
×
163
                            )
×
164
                        ));
×
165
                    }
×
166
                }
167

168
                match style.code.copy_link {
×
169
                    LinkStyle::Off => {}
×
170
                    LinkStyle::Full => {
×
171
                        links.push(format!(
×
172
                            "{}copy: copy://{}",
×
173
                            " ".repeat(*indent),
×
174
                            path.display()
×
175
                        ));
×
176
                    }
×
177
                    LinkStyle::Osc8 => {
×
178
                        links.push(format!(
×
179
                            "{}[{}]",
×
180
                            " ".repeat(*indent),
×
181
                            hyperlink(
×
182
                                format!("copy://{}", path.display()),
×
183
                                "copy to clipboard".red().to_string()
×
184
                            )
×
185
                        ));
×
186
                    }
×
187
                }
188

189
                self.in_fenced_code_block = false;
×
190

191
                let mut lines = vec![content.with(Color::AnsiValue(238)).to_string()];
×
192
                if !links.is_empty() {
×
193
                    lines.push(links.join(" "));
×
194
                }
×
195

196
                Ok(lines)
×
197
            }
198
            LineVariant::Normal => {
199
                // We feed all the lines for markdown formatting, but only
200
                // print the last one, as the others are already printed.
201
                //
202
                // This helps the parser to use previous context to apply
203
                // the correct formatting to the current line.
204
                //
205
                // We only care about the lines after the last code block
206
                // end, because a) formatting context is reset after a code
207
                // block, and b) we dot not limit the line length of code, makes
208
                // it impossible to correctly find the non-printed lines based
209
                // on wrapped vs non-wrapped lines.
210
                let lines = self
×
211
                    .received
×
212
                    .iter()
×
213
                    .skip(self.last_fenced_code_block_end.0)
×
214
                    .cloned()
×
215
                    .collect::<Vec<_>>();
×
216

217
                // `termimad` removes empty lines at the start or end, but we
218
                // want to keep them as we will have more lines to print.
219
                let empty_lines_start_count = lines.iter().take_while(|s| s.is_empty()).count();
×
220
                let empty_lines_end_count = lines.iter().rev().take_while(|s| s.is_empty()).count();
×
221

222
                let options = comrak::Options {
×
223
                    render: comrak::RenderOptions {
×
224
                        unsafe_: true,
×
225
                        prefer_fenced: true,
×
226
                        experimental_minimize_commonmark: true,
×
227
                        ..Default::default()
×
228
                    },
×
229
                    ..Default::default()
×
230
                };
×
231

232
                let formatted = comrak::markdown_to_commonmark(&lines.join("\n"), &options);
×
233

234
                let mut formatted =
×
235
                    FmtText::from(&termimad::MadSkin::default(), &formatted, Some(100)).to_string();
×
236

237
                for _ in 0..empty_lines_start_count {
×
238
                    formatted.insert(0, '\n');
×
239
                }
×
240

241
                // Only add an extra newline if we have more than one line,
242
                // otherwise a single empty line will be interpreted as both a
243
                // missing start and end newline.
244
                if lines.iter().any(|s| !s.is_empty()) {
×
245
                    for _ in 0..empty_lines_end_count {
×
246
                        formatted.push('\n');
×
247
                    }
×
248
                }
×
249

250
                let lines = formatted
×
251
                    .lines()
×
252
                    .skip(self.parsed.len() - self.last_fenced_code_block_end.1)
×
253
                    .map(ToOwned::to_owned)
×
254
                    .collect::<Vec<_>>();
×
255

256
                Ok(lines)
×
257
            }
258
        }
259
    }
×
260

261
    fn persist_code_block(&self) -> Result<PathBuf, Error> {
×
262
        let code = self.code_buffer.1.clone();
×
263
        let language = self.code_buffer.0.as_deref().unwrap_or("txt");
×
264
        let ext = match language {
×
265
            "c++" => "cpp",
×
266
            "javascript" => "js",
×
267
            "python" => "py",
×
268
            "ruby" => "rb",
×
269
            "rust" => "rs",
×
270
            "typescript" => "ts",
×
271
            lang => lang,
×
272
        };
273

274
        let millis = std::time::SystemTime::now()
×
275
            .duration_since(std::time::UNIX_EPOCH)
×
276
            .unwrap_or_default()
×
277
            .subsec_millis();
×
278
        let path = std::env::temp_dir().join(format!("code_{millis}.{ext}"));
×
279

280
        fs::write(&path, code.join("\n"))?;
×
281

282
        Ok(path)
×
283
    }
×
284
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc