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

dcdpr / jp / 28433558350

30 Jun 2026 09:14AM UTC coverage: 67.504% (+0.01%) from 67.491%
28433558350

Pull #814

github

web-flow
Merge aa496ee83 into 860884ca1
Pull Request #814: feat(editor, inquire, cli, config): Add inline reply widget and unified editor backend

353 of 512 new or added lines in 16 files covered. (68.95%)

21 existing lines in 5 files now uncovered.

37481 of 55524 relevant lines covered (67.5%)

661.64 hits per line

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

93.22
/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, `s` while tools run) opens the
13
//! inline reply widget ([`jp_inquire::InlineReply`]), which renders to the
14
//! caller's `/dev/tty` writer and offers a `Ctrl+X` escape to the configured
15
//! external editor.
16
//! Setting `interrupt.{streaming,tool_call}.reply_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
        StreamingInterruptAction, StreamingInterruptConfig, ToolInterruptAction,
35
        ToolInterruptConfig,
36
    },
37
};
38
use jp_editor::{EditOutcome, EditorBackend};
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✔
NEW
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(String),
75

76
    /// Resume generation (if stream is alive) or wait (if tool is running).
77
    Resume,
78

79
    /// Continue generation from partial content using assistant prefill.
80
    ///
81
    /// When the stream has died (e.g., due to timeout), we can inject the
82
    /// partial content as an assistant message and ask the LLM to continue from
83
    /// there.
84
    Continue,
85

86
    /// Cancel all running tools and restart the entire batch.
87
    RestartTool,
88

89
    /// Cancel all running tools and return a user-supplied response to the LLM.
90
    ///
91
    /// If the user leaves the response empty, a canned message is used that
92
    /// instructs the LLM to evaluate why the tool was rejected.
93
    ToolCancelled { response: String },
94
}
95

96
/// Outcome of collecting a reply from the user.
97
enum ReplyResult {
98
    /// The user submitted a non-empty reply.
99
    Reply(String),
100

101
    /// The user backed out: an empty submission, a cancel, or an emptied or
102
    /// aborted editor.
103
    /// The call site decides what backing out means (return to the menu, use a
104
    /// canned message, …).
105
    Back,
106
}
107

108
/// Handles user interrupts (Ctrl+C) during query execution.
109
///
110
/// This handler presents interactive menus and returns the user's chosen
111
/// action.
112
/// The actual handling of the action is done by the caller.
113
///
114
/// Uses [`PromptBackend`] for dependency injection, enabling testing without a
115
/// TTY.
116
pub struct InterruptHandler<P: PromptBackend = TerminalPromptBackend> {
117
    /// Backend that renders the menu and the inline reply prompt.
118
    backend: P,
119

120
    /// The configured editor, used for the `Ctrl+X` escape and the
121
    /// `reply_in_editor` opt-in.
122
    /// `None` when no editor is configured; the inline widget still works.
123
    editor: Option<Arc<dyn EditorBackend>>,
124

125
    /// The inline reply buffer's editing style.
126
    edit_mode: ReplyEditMode,
127
}
128

129
impl Default for InterruptHandler<TerminalPromptBackend> {
130
    fn default() -> Self {
×
NEW
131
        Self::with_backend(TerminalPromptBackend, None, ReplyEditMode::Emacs)
×
132
    }
×
133
}
134

135
impl<P: PromptBackend> InterruptHandler<P> {
136
    /// Create an interrupt handler with a custom prompt backend, an optional
137
    /// editor (for the reply escape hatch), and the inline edit mode.
138
    pub fn with_backend(
38✔
139
        backend: P,
38✔
140
        editor: Option<Arc<dyn EditorBackend>>,
38✔
141
        edit_mode: ReplyEditMode,
38✔
142
    ) -> Self {
38✔
143
        Self {
38✔
144
            backend,
38✔
145
            editor,
38✔
146
            edit_mode,
38✔
147
        }
38✔
148
    }
38✔
149

150
    /// Handle an interrupt during LLM streaming.
151
    ///
152
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
153
    /// the configured action runs directly without a menu.
154
    /// Choosing `reply` collects a reply; backing out of a menu-driven reply
155
    /// returns to the menu, while a configured (menu-less) `reply` resumes.
156
    pub fn handle_streaming_interrupt(
24✔
157
        &self,
24✔
158
        config: &StreamingInterruptConfig,
24✔
159
        printer: &Printer,
24✔
160
        stream_alive: bool,
24✔
161
    ) -> InterruptAction {
24✔
162
        let menu = config.action == StreamingInterruptAction::Prompt;
24✔
163

164
        loop {
165
            let choice = match config.action {
28✔
166
                StreamingInterruptAction::Prompt => {
167
                    let options = vec![
21✔
168
                        InlineOption::new('c', "Continue"),
21✔
169
                        InlineOption::new('r', "Reply (stop & respond)"),
21✔
170
                        InlineOption::new('s', "Stop (save & exit)"),
21✔
171
                        InlineOption::new('a', "Abort (discard & exit)"),
21✔
172
                    ];
173

174
                    // A cancelled menu falls back to a graceful stop. (RFD 045's
175
                    // `Escalated` outcome is not yet implemented; this is the
176
                    // graceful-shutdown stand-in.)
177
                    self.backend
21✔
178
                        .inline_select("Interrupted", options, None, &mut printer.prompt_writer())
21✔
179
                        .unwrap_or('s')
21✔
180
                }
181
                StreamingInterruptAction::Continue => 'c',
2✔
182
                StreamingInterruptAction::Reply => 'r',
3✔
183
                StreamingInterruptAction::Stop => 's',
1✔
184
                StreamingInterruptAction::Abort => 'a',
1✔
185
            };
186

187
            match choice {
6✔
188
                'c' if stream_alive => return InterruptAction::Resume,
3✔
189
                'c' => return InterruptAction::Continue,
3✔
190
                's' => return InterruptAction::Stop,
6✔
191
                'a' => return InterruptAction::Abort,
2✔
192
                'r' => match self.collect_reply("Reply:", config.reply_in_editor, printer) {
14✔
193
                    ReplyResult::Reply(text) => return InterruptAction::Reply(text),
8✔
194
                    // Backing out of a menu-driven reply re-shows the menu (the
195
                    // loop iterates).
196
                    ReplyResult::Back if menu => {}
4✔
197
                    // A configured (menu-less) reply has no menu to return to,
198
                    // so it mirrors the `'c'` branch: keep polling a live
199
                    // stream, otherwise continue from the partial response.
200
                    ReplyResult::Back if stream_alive => return InterruptAction::Resume,
1✔
201
                    ReplyResult::Back => return InterruptAction::Continue,
1✔
202
                },
NEW
203
                _ => unreachable!("unexpected interrupt choice"),
×
204
            }
205
        }
206
    }
24✔
207

208
    /// Handle an interrupt during tool execution.
209
    ///
210
    /// Presents a menu with options to stop & reply, restart, or continue
211
    /// waiting.
212
    /// When the user chooses "Stop & Reply", they can supply a custom message;
213
    /// an empty or cancelled reply produces the canned default.
214
    ///
215
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
216
    /// the configured action runs directly without a menu.
217
    pub fn handle_tool_interrupt(
14✔
218
        &self,
14✔
219
        config: &ToolInterruptConfig,
14✔
220
        printer: &Printer,
14✔
221
    ) -> InterruptAction {
14✔
222
        let choice = match config.action {
14✔
223
            ToolInterruptAction::Prompt => {
224
                let options = vec![
12✔
225
                    InlineOption::new('c', "Continue"),
12✔
226
                    InlineOption::new('s', "Stop & Reply"),
12✔
227
                    InlineOption::new('r', "Restart"),
12✔
228
                ];
229

230
                self.backend
12✔
231
                    .inline_select("Interrupted", options, None, &mut printer.prompt_writer())
12✔
232
                    .unwrap_or('c')
12✔
233
            }
234
            ToolInterruptAction::Continue => 'c',
×
235
            ToolInterruptAction::Restart => 'r',
1✔
236
            ToolInterruptAction::StopReply => 's',
1✔
237
        };
238

239
        match choice {
14✔
240
            'c' => InterruptAction::Resume,
3✔
241
            'r' => InterruptAction::RestartTool,
3✔
242
            's' => {
243
                // An empty or cancelled reply falls through to the canned
244
                // message, preserving the "interrupt a tool with no
245
                // explanation" shortcut.
246
                let response = match self.collect_reply("Reply:", config.reply_in_editor, printer) {
8✔
247
                    ReplyResult::Reply(text) => text,
3✔
248
                    ReplyResult::Back => DEFAULT_TOOL_CANCELLED_RESPONSE.to_owned(),
5✔
249
                };
250

251
                InterruptAction::ToolCancelled { response }
8✔
252
            }
NEW
253
            _ => unreachable!("unexpected interrupt choice"),
×
254
        }
255
    }
14✔
256

257
    /// Collect a reply, honoring the straight-to-editor opt-in.
258
    ///
259
    /// With `reply_in_editor` set and an editor configured, opens the editor
260
    /// seeded empty; otherwise collects through the inline widget.
261
    fn collect_reply(
22✔
262
        &self,
22✔
263
        message: &str,
22✔
264
        reply_in_editor: bool,
22✔
265
        printer: &Printer,
22✔
266
    ) -> ReplyResult {
22✔
267
        if reply_in_editor {
22✔
268
            let Some(editor) = self.editor.as_ref() else {
4✔
269
                // No editor configured: fall back to the inline widget rather
270
                // than silently doing nothing.
271
                return self.collect_reply_inline(message, printer);
1✔
272
            };
273

274
            return match editor.edit_text("") {
3✔
275
                Ok((EditOutcome::Saved, text)) if !text.trim().is_empty() => {
2✔
276
                    ReplyResult::Reply(text)
1✔
277
                }
278
                // Empty save or a cancelled (non-zero-exit) editor: back out.
279
                Ok(_) => ReplyResult::Back,
1✔
280
                // A spawn / I/O failure is not a user cancellation: surface it
281
                // (chrome + diagnostics) and fall back to the inline widget so
282
                // the user can still reply.
283
                Err(error) => {
1✔
284
                    report_editor_failure(printer, &error, "Continuing with the inline editor.");
1✔
285
                    self.collect_reply_inline(message, printer)
1✔
286
                }
287
            };
288
        }
18✔
289

290
        self.collect_reply_inline(message, printer)
18✔
291
    }
22✔
292

293
    /// Collect a reply through the inline widget, looping on the editor escape.
294
    ///
295
    /// Prompt errors and `Ctrl+C` are handled explicitly (never swallowed): a
296
    /// non-`Submit` outcome or an error backs out.
297
    fn collect_reply_inline(&self, message: &str, printer: &Printer) -> ReplyResult {
20✔
298
        let mut buffer = String::new();
20✔
299
        loop {
300
            let output = printer.owned_prompt_writer();
22✔
301
            match self
22✔
302
                .backend
22✔
303
                .inline_reply(message, &buffer, self.edit_mode, output)
22✔
304
            {
305
                Ok(ReplyOutcome::OpenEditor { current_text }) => {
3✔
306
                    let Some(editor) = self.editor.as_ref() else {
3✔
307
                        // No editor configured: the escape is a no-op, the
308
                        // widget stays open with the buffer intact.
NEW
309
                        continue;
×
310
                    };
311

312
                    match editor.edit_text(&current_text) {
3✔
313
                        Ok((EditOutcome::Saved, edited)) if !edited.trim().is_empty() => {
2✔
314
                            // Re-seed the inline prompt with the editor's output.
1✔
315
                            buffer = edited;
1✔
316
                        }
1✔
317
                        // Emptied or cancelled editor: back out.
318
                        Ok(_) => return ReplyResult::Back,
1✔
319
                        // A spawn / I/O failure is not a user cancellation:
320
                        // surface it (chrome + diagnostics) and keep the buffer,
321
                        // re-prompting rather than discarding what the user
322
                        // typed.
323
                        Err(error) => {
1✔
324
                            report_editor_failure(printer, &error, "Keeping your text.");
1✔
325
                            buffer = current_text;
1✔
326
                        }
1✔
327
                    }
328
                }
329
                Ok(ReplyOutcome::Submit(text)) if !text.trim().is_empty() => {
13✔
330
                    return ReplyResult::Reply(text);
10✔
331
                }
332
                // A blank (empty or whitespace-only) submission, a `Ctrl+C`
333
                // cancel, or a prompt error all back out. Whitespace is treated
334
                // as blank so the tool path falls through to its canned
335
                // rejection rather than sending a blank-looking reply.
336
                Ok(ReplyOutcome::Submit(_) | ReplyOutcome::Cancelled) | Err(_) => {
337
                    return ReplyResult::Back;
9✔
338
                }
339
            }
340
        }
341
    }
20✔
342
}
343

344
#[cfg(test)]
345
#[path = "handler_tests.rs"]
346
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