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

dcdpr / jp / 28664847469

03 Jul 2026 01:50PM UTC coverage: 67.553%. First build
28664847469

Pull #838

github

web-flow
Merge 361e60d00 into b9f599a21
Pull Request #838: fix(cli): Skip echo for inline-composed interrupt replies

11 of 12 new or added lines in 2 files covered. (91.67%)

38158 of 56486 relevant lines covered (67.55%)

658.64 hits per line

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

92.72
/crates/jp_cli/src/cmd/query/interrupt/handler.rs
1
//! Interrupt handling for the query stream pipeline.
2
//!
3
//! When the user presses Ctrl+C during a query, the `InterruptHandler` presents
4
//! a context-aware menu based on the current state (streaming vs tool
5
//! execution).
6
//!
7
//! The handler returns an [`InterruptAction`] that the caller can use to
8
//! determine the next step.
9
//!
10
//! ## Replies
11
//!
12
//! Choosing to reply (`r` while streaming or while tools run) opens the inline
13
//! reply widget ([`jp_inquire::InlineReply`]), which renders to the caller's
14
//! `/dev/tty` writer and offers a `Ctrl+X` escape to the configured external
15
//! editor.
16
//! Setting `interrupt.{streaming,tool_call}.compose_in_editor` skips the inline
17
//! step and opens the editor directly.
18
//!
19
//! ## Testing
20
//!
21
//! The handler uses dependency injection via [`PromptBackend`] to enable
22
//! testing without a real TTY.
23
//! In production, [`TerminalPromptBackend`] uses [`jp_inquire`].
24
//! In tests, [`MockPromptBackend`] provides pre-programmed responses.
25
//!
26
//! [`MockPromptBackend`]: jp_inquire::prompt::MockPromptBackend
27
//! [`TerminalPromptBackend`]: jp_inquire::prompt::TerminalPromptBackend
28

29
use std::sync::Arc;
30

31
use jp_config::{
32
    editor::InlineEditMode,
33
    interrupt::{
34
        ComposeInEditor, StreamingInterruptAction, StreamingInterruptConfig, ToolInterruptAction,
35
        ToolInterruptConfig,
36
    },
37
};
38
use jp_editor::{EditOutcome, EditorBackend, EditorError};
39
use jp_inquire::{
40
    InlineOption, ReplyEditMode, ReplyOutcome,
41
    prompt::{PromptBackend, TerminalPromptBackend},
42
};
43
use jp_printer::Printer;
44

45
use crate::editor::report_editor_failure;
46

47
/// Default response sent to the LLM when the user cancels a tool without
48
/// supplying a custom message.
49
const DEFAULT_TOOL_CANCELLED_RESPONSE: &str = indoc::concatdoc! {"
50
    This tool request was intentionally rejected by the user. \
51
    Please evaluate and either ask the user why it was rejected, \
52
    or infer the reason by looking at the historical messages \
53
    in the conversation.\
54
"};
55

56
/// Map the configured inline edit mode onto the reply widget's edit mode.
57
pub(crate) fn reply_edit_mode(mode: InlineEditMode) -> ReplyEditMode {
59✔
58
    match mode {
59✔
59
        InlineEditMode::Emacs => ReplyEditMode::Emacs,
59✔
60
        InlineEditMode::Vi => ReplyEditMode::Vi,
×
61
    }
62
}
59✔
63

64
/// Actions that can be taken after an interrupt.
65
#[derive(Debug, Clone, PartialEq, Eq)]
66
pub enum InterruptAction {
67
    /// Stop generation gracefully.
68
    Stop,
69

70
    /// Abort generation, without saving the current cycle.
71
    Abort,
72

73
    /// Stop generation and immediately reply with a new user message.
74
    Reply {
75
        /// The reply text.
76
        content: String,
77

78
        /// Whether the reply was composed in the external editor.
79
        ///
80
        /// An editor-composed reply never appeared on the terminal, so the
81
        /// caller should echo it back.
82
        /// An inline-composed reply is already visible in scrollback on the
83
        /// widget's own line.
84
        from_editor: bool,
85
    },
86

87
    /// Resume generation (if stream is alive) or wait (if tool is running).
88
    Resume,
89

90
    /// Continue generation from partial content using assistant prefill.
91
    ///
92
    /// When the stream has died (e.g., due to timeout), we can inject the
93
    /// partial content as an assistant message and ask the LLM to continue from
94
    /// there.
95
    Continue,
96

97
    /// Cancel all running tools and restart the entire batch.
98
    RestartTool,
99

100
    /// Cancel all running tools and return a user-supplied response to the LLM.
101
    ///
102
    /// If the user leaves the response empty, a canned message is used that
103
    /// instructs the LLM to evaluate why the tool was rejected.
104
    ToolCancelled { response: String },
105
}
106

107
/// Outcome of collecting a reply from the user.
108
enum ReplyResult {
109
    /// The user submitted a non-empty reply.
110
    /// `from_editor` records the composing surface: `true` when the text came
111
    /// straight from the external editor (never rendered on the terminal),
112
    /// `false` when it was submitted from the inline widget (visible in
113
    /// scrollback).
114
    Reply { text: String, from_editor: bool },
115

116
    /// The user submitted an empty (or whitespace-only) reply: "send nothing".
117
    /// The call site commits forward (the canned tool message, or back to a
118
    /// streaming menu that has no separate nothing-to-send action).
119
    Empty,
120

121
    /// The user pressed `Ctrl+C` (or the prompt errored): "back up a level".
122
    /// The call site returns to the interrupt menu where one exists, and
123
    /// otherwise falls back like `Empty`.
124
    Cancelled,
125
}
126

127
/// Handles user interrupts (Ctrl+C) during query execution.
128
///
129
/// This handler presents interactive menus and returns the user's chosen
130
/// action.
131
/// The actual handling of the action is done by the caller.
132
///
133
/// Uses [`PromptBackend`] for dependency injection, enabling testing without a
134
/// TTY.
135
pub struct InterruptHandler<P: PromptBackend = TerminalPromptBackend> {
136
    /// Backend that renders the menu and the inline reply prompt.
137
    backend: P,
138

139
    /// The configured editor, used for the `Ctrl+X` escape and the
140
    /// `compose_in_editor` opt-in.
141
    /// `None` when no editor is configured; the inline widget still works.
142
    editor: Option<Arc<dyn EditorBackend>>,
143

144
    /// The inline reply buffer's editing style.
145
    edit_mode: ReplyEditMode,
146
}
147

148
impl Default for InterruptHandler<TerminalPromptBackend> {
149
    fn default() -> Self {
×
150
        Self::with_backend(TerminalPromptBackend, None, ReplyEditMode::Emacs)
×
151
    }
×
152
}
153

154
impl<P: PromptBackend> InterruptHandler<P> {
155
    /// Create an interrupt handler with a custom prompt backend, an optional
156
    /// editor (for the reply escape hatch), and the inline edit mode.
157
    pub fn with_backend(
41✔
158
        backend: P,
41✔
159
        editor: Option<Arc<dyn EditorBackend>>,
41✔
160
        edit_mode: ReplyEditMode,
41✔
161
    ) -> Self {
41✔
162
        Self {
41✔
163
            backend,
41✔
164
            editor,
41✔
165
            edit_mode,
41✔
166
        }
41✔
167
    }
41✔
168

169
    /// Handle an interrupt during LLM streaming.
170
    ///
171
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
172
    /// the configured action runs directly without a menu.
173
    /// Choosing `reply` collects a reply; backing out of a menu-driven reply
174
    /// returns to the menu, while a configured (menu-less) `reply` resumes.
175
    pub fn handle_streaming_interrupt(
26✔
176
        &self,
26✔
177
        config: &StreamingInterruptConfig,
26✔
178
        printer: &Printer,
26✔
179
        stream_alive: bool,
26✔
180
    ) -> InterruptAction {
26✔
181
        let menu = config.action == StreamingInterruptAction::Prompt;
26✔
182

183
        loop {
184
            let choice = match config.action {
31✔
185
                StreamingInterruptAction::Prompt => {
186
                    let options = vec![
24✔
187
                        InlineOption::new('c', "Continue"),
24✔
188
                        InlineOption::new('r', "Reply (stop & respond)"),
24✔
189
                        InlineOption::new('s', "Stop (save & exit)"),
24✔
190
                        InlineOption::new('a', "Abort (discard & exit)"),
24✔
191
                    ];
192

193
                    // A cancelled menu falls back to a graceful stop. (RFD 045's
194
                    // `Escalated` outcome is not yet implemented; this is the
195
                    // graceful-shutdown stand-in.)
196
                    self.backend
24✔
197
                        .inline_select("Interrupted", options, None, &mut printer.prompt_writer())
24✔
198
                        .unwrap_or('s')
24✔
199
                }
200
                StreamingInterruptAction::Continue => 'c',
2✔
201
                StreamingInterruptAction::Reply => 'r',
3✔
202
                StreamingInterruptAction::Stop => 's',
1✔
203
                StreamingInterruptAction::Abort => 'a',
1✔
204
            };
205

206
            match choice {
6✔
207
                'c' if stream_alive => return InterruptAction::Resume,
3✔
208
                'c' => return InterruptAction::Continue,
3✔
209
                's' => return InterruptAction::Stop,
7✔
210
                'a' => return InterruptAction::Abort,
2✔
211
                'r' => match self.collect_reply("Reply:", config.compose_in_editor, printer) {
16✔
212
                    ReplyResult::Reply { text, from_editor } => {
9✔
213
                        return InterruptAction::Reply {
9✔
214
                            content: text,
9✔
215
                            from_editor,
9✔
216
                        };
9✔
217
                    }
218
                    // Empty submit or `Ctrl+C` in a menu-driven reply re-shows
219
                    // the menu (the loop iterates).
220
                    ReplyResult::Empty | ReplyResult::Cancelled if menu => {}
5✔
221
                    // A configured (menu-less) reply has no menu to return to,
222
                    // so it mirrors the `'c'` branch: keep polling a live
223
                    // stream, otherwise continue from the partial response.
224
                    ReplyResult::Empty | ReplyResult::Cancelled if stream_alive => {
×
225
                        return InterruptAction::Resume;
1✔
226
                    }
227
                    ReplyResult::Empty | ReplyResult::Cancelled => {
228
                        return InterruptAction::Continue;
1✔
229
                    }
230
                },
NEW
231
                _ => unreachable!("unexpected interrupt choice"),
×
232
            }
233
        }
234
    }
26✔
235

236
    /// Handle an interrupt during tool execution.
237
    ///
238
    /// Presents a menu with options to stop & respond, restart, or continue
239
    /// waiting.
240
    /// Choosing "Stop & respond" collects a response: a typed message stops the
241
    /// tool and sends it, an empty submission stops with the canned default,
242
    /// and `Ctrl+C` backs out to the menu.
243
    ///
244
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
245
    /// the configured action runs directly without a menu.
246
    pub fn handle_tool_interrupt(
15✔
247
        &self,
15✔
248
        config: &ToolInterruptConfig,
15✔
249
        printer: &Printer,
15✔
250
    ) -> InterruptAction {
15✔
251
        let menu = config.action == ToolInterruptAction::Prompt;
15✔
252

253
        loop {
254
            let choice = match config.action {
16✔
255
                ToolInterruptAction::Prompt => {
256
                    let options = vec![
13✔
257
                        InlineOption::new('c', "Continue"),
13✔
258
                        InlineOption::new('r', "Stop & respond"),
13✔
259
                        InlineOption::new('t', "Restart"),
13✔
260
                    ];
261

262
                    self.backend
13✔
263
                        .inline_select("Interrupted", options, None, &mut printer.prompt_writer())
13✔
264
                        .unwrap_or('c')
13✔
265
                }
266
                ToolInterruptAction::Continue => 'c',
×
267
                ToolInterruptAction::Restart => 't',
1✔
268
                ToolInterruptAction::Respond => 'r',
2✔
269
            };
270

271
            match choice {
16✔
272
                'c' => return InterruptAction::Resume,
4✔
273
                't' => return InterruptAction::RestartTool,
3✔
274
                'r' => match self.collect_reply("Reply:", config.compose_in_editor, printer) {
9✔
275
                    ReplyResult::Reply { text, .. } => {
3✔
276
                        return InterruptAction::ToolCancelled { response: text };
3✔
277
                    }
278
                    // `Ctrl+C` backs up to the menu (the loop iterates). A
279
                    // menu-less configured `respond` has no menu, so it falls
280
                    // through to the canned message below.
281
                    ReplyResult::Cancelled if menu => {}
1✔
282
                    // An empty submission stops the tool with the canned "no
283
                    // explanation" message; so does a menu-less `Ctrl+C`.
284
                    ReplyResult::Empty | ReplyResult::Cancelled => {
285
                        return InterruptAction::ToolCancelled {
5✔
286
                            response: DEFAULT_TOOL_CANCELLED_RESPONSE.to_owned(),
5✔
287
                        };
5✔
288
                    }
289
                },
290
                _ => unreachable!("unexpected interrupt choice"),
×
291
            }
292
        }
293
    }
15✔
294

295
    /// Collect a reply according to the `compose_in_editor` mode.
296
    ///
297
    /// - `false` / `"never"`: collect through the inline widget (the `Ctrl+X`
298
    ///   editor escape is wired only for `false`).
299
    /// - `true` / `"always"`: open the editor seeded empty.
300
    ///   A non-empty save is sent; an empty or cancelled editor returns to the
301
    ///   menu.
302
    ///   When the editor can't run, `true` falls back to the inline widget and
303
    ///   `"always"` returns to the menu (never the inline widget).
304
    fn collect_reply(
25✔
305
        &self,
25✔
306
        message: &str,
25✔
307
        compose: ComposeInEditor,
25✔
308
        printer: &Printer,
25✔
309
    ) -> ReplyResult {
25✔
310
        // Inline-first modes (`false` / `"never"`).
311
        if !compose.starts_in_editor() {
25✔
312
            return self.collect_reply_inline(message, compose.editor_escape(), printer);
20✔
313
        }
5✔
314

315
        // Editor-first modes (`true` / `"always"`): open the editor directly.
316
        let Some(editor) = self.editor.as_ref() else {
5✔
317
            // No editor configured: nothing to open.
318
            return self.editor_unavailable(message, compose, printer, None);
1✔
319
        };
320

321
        match editor.edit_text("") {
4✔
322
            Ok((EditOutcome::Saved, text)) if !text.trim().is_empty() => ReplyResult::Reply {
2✔
323
                text,
1✔
324
                from_editor: true,
1✔
325
            },
1✔
326
            // Empty save or a cancelled (non-zero-exit) editor: the user bailed,
327
            // so return to the menu.
328
            Ok(_) => ReplyResult::Cancelled,
1✔
329
            // The editor could not run.
330
            Err(error) => self.editor_unavailable(message, compose, printer, Some(error)),
2✔
331
        }
332
    }
25✔
333

334
    /// Handle an editor-first mode (`true` / `"always"`) when the editor can't
335
    /// be used — a spawn/I/O failure or no editor configured.
336
    ///
337
    /// `true` falls back to the inline widget so the user can still reply;
338
    /// `"always"` returns to the menu, never the inline widget (the user opted
339
    /// out of it).
340
    /// A spawn failure is surfaced on the chrome channel.
341
    fn editor_unavailable(
3✔
342
        &self,
3✔
343
        message: &str,
3✔
344
        compose: ComposeInEditor,
3✔
345
        printer: &Printer,
3✔
346
        error: Option<EditorError>,
3✔
347
    ) -> ReplyResult {
3✔
348
        if compose.falls_back_to_inline() {
3✔
349
            if let Some(error) = error {
2✔
350
                report_editor_failure(printer, &error, "Continuing with the inline editor.");
1✔
351
            }
1✔
352
            return self.collect_reply_inline(message, compose.editor_escape(), printer);
2✔
353
        }
1✔
354

355
        // `"always"`: never the inline widget.
356
        match error {
1✔
357
            Some(error) => report_editor_failure(printer, &error, "Returning to the menu."),
1✔
358
            None => printer.eprintln("\n⚠ No editor configured; returning to the menu."),
×
359
        }
360
        ReplyResult::Cancelled
1✔
361
    }
3✔
362

363
    /// Collect a reply through the inline widget.
364
    ///
365
    /// The `Ctrl+X` editor escape always returns to the inline prompt — it is
366
    /// never a terminal action.
367
    /// The only exits are a submission (empty or not) and `Ctrl+C` (or a prompt
368
    /// error), handled explicitly, never swallowed.
369
    fn collect_reply_inline(
22✔
370
        &self,
22✔
371
        message: &str,
22✔
372
        editor_escape: bool,
22✔
373
        printer: &Printer,
22✔
374
    ) -> ReplyResult {
22✔
375
        let mut buffer = String::new();
22✔
376
        loop {
377
            let output = printer.owned_prompt_writer();
25✔
378
            match self
25✔
379
                .backend
25✔
380
                .inline_reply(message, &buffer, self.edit_mode, editor_escape, output)
25✔
381
            {
382
                Ok(ReplyOutcome::OpenEditor { current_text }) => {
3✔
383
                    // The editor escape always returns here; whatever was typed
384
                    // before `Ctrl+X` is preserved.
385
                    buffer = current_text;
3✔
386
                    if let Some(editor) = self.editor.as_ref() {
3✔
387
                        match editor.edit_text(&buffer) {
3✔
388
                            // Re-seed with the editor's output, even if empty.
389
                            Ok((EditOutcome::Saved, edited)) => buffer = edited,
2✔
390
                            // Aborted editor: keep the buffer as it was.
391
                            Ok((EditOutcome::Cancelled, _)) => {}
×
392
                            // A spawn / I/O failure is surfaced (chrome +
393
                            // diagnostics); the buffer is kept.
394
                            Err(error) => {
1✔
395
                                report_editor_failure(printer, &error, "Keeping your text.");
1✔
396
                            }
1✔
397
                        }
398
                    }
×
399
                }
400
                Ok(ReplyOutcome::Submit(text)) if !text.trim().is_empty() => {
17✔
401
                    // Even after a `Ctrl+X` round-trip the editor's output is
402
                    // re-seeded here and submitted from the widget, so the
403
                    // final text is visible on the terminal.
404
                    return ReplyResult::Reply {
11✔
405
                        text,
11✔
406
                        from_editor: false,
11✔
407
                    };
11✔
408
                }
409
                // A blank (empty or whitespace-only) submission commits forward
410
                // with nothing; `Ctrl+C` or a prompt error backs up a level.
411
                // Whitespace counts as blank so the tool path reaches its canned
412
                // rejection rather than sending a blank-looking reply.
413
                Ok(ReplyOutcome::Submit(_)) => return ReplyResult::Empty,
6✔
414
                Ok(ReplyOutcome::Cancelled) | Err(_) => return ReplyResult::Cancelled,
5✔
415
            }
416
        }
417
    }
22✔
418
}
419

420
#[cfg(test)]
421
#[path = "handler_tests.rs"]
422
mod tests;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc