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

dcdpr / jp / 28454779385

30 Jun 2026 03:09PM UTC coverage: 67.605% (+0.03%) from 67.575%
28454779385

push

github

web-flow
feat(editor, inquire, cli, config): Add inline reply widget and unified editor backend (#814)

JP now accepts replies inline — without requiring a configured external
editor.
While the assistant is streaming, or while tools are running, pressing
`Ctrl+C`
and choosing `r` opens a rich, multi-line prompt built on `reedline`
instead of
spawning an editor. `Ctrl+X` inside the widget escapes to the external
editor
and returns with its contents; `Ctrl+C` backs out to the interrupt menu.

The same inline widget drives the tool argument-edit, result-edit, and
skip-reasoning flows in `ToolPrompter`. None of these require a
configured
editor anymore — the editor is the escape hatch, not the only path.

## Configuring your editor

The file being edited is appended as the final argument, so editors that
fork
into the background must be told to wait. Pick the line for your editor:

```toml
[editor]
# VS Code — `--wait` is required so JP blocks until you close the tab.
cmd = "code --wait"

# Other common editors (uncomment one):
# cmd = "vim"                                  # vim (use "nvim" for Neovim)
# cmd = "hx"                                   # Helix
# cmd = "subl --wait"                          # Sublime Text
# cmd = "zed --wait"                           # Zed
# cmd = "emacsclient -nw --alternate-editor="  # Emacs (reuses a running daemon)

# Editors that need a pipeline or shell wrapper use the table form with
# `shell = true` (Unix only — on Windows wrap the logic in a script and point
# `program` at it with `shell = false`):
# cmd = { program = "my-editor-wrapper", args = ["--flag"], shell = true }

# If `cmd` is unset, JP falls back to these environment variables, in order:
envs = ["JP_EDITOR", "VISUAL", "EDITOR"]

[editor.inline]
# Keybindings for JP's *inline* reply widget — independent of which external
# editor `Ctrl+X` opens.
edit_mode = "emacs"   # "emacs" (default) or "vi"

[interrupt.streaming]
action = "prompt"          # prompt | continue |... (continued)

445 of 629 new or added lines in 16 files covered. (70.75%)

21 existing lines in 5 files now uncovered.

37656 of 55700 relevant lines covered (67.61%)

676.31 hits per line

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

92.2
/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✔
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 submitted an empty (or whitespace-only) reply: "send nothing".
102
    /// The call site commits forward (the canned tool message, or back to a
103
    /// streaming menu that has no separate nothing-to-send action).
104
    Empty,
105

106
    /// The user pressed `Ctrl+C` (or the prompt errored): "back up a level".
107
    /// The call site returns to the interrupt menu where one exists, and
108
    /// otherwise falls back like `Empty`.
109
    Cancelled,
110
}
111

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

124
    /// The configured editor, used for the `Ctrl+X` escape and the
125
    /// `compose_in_editor` opt-in.
126
    /// `None` when no editor is configured; the inline widget still works.
127
    editor: Option<Arc<dyn EditorBackend>>,
128

129
    /// The inline reply buffer's editing style.
130
    edit_mode: ReplyEditMode,
131
}
132

133
impl Default for InterruptHandler<TerminalPromptBackend> {
134
    fn default() -> Self {
×
NEW
135
        Self::with_backend(TerminalPromptBackend, None, ReplyEditMode::Emacs)
×
136
    }
×
137
}
138

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

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

168
        loop {
169
            let choice = match config.action {
31✔
170
                StreamingInterruptAction::Prompt => {
171
                    let options = vec![
24✔
172
                        InlineOption::new('c', "Continue"),
24✔
173
                        InlineOption::new('r', "Reply (stop & respond)"),
24✔
174
                        InlineOption::new('s', "Stop (save & exit)"),
24✔
175
                        InlineOption::new('a', "Abort (discard & exit)"),
24✔
176
                    ];
177

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

191
            match choice {
6✔
192
                'c' if stream_alive => return InterruptAction::Resume,
3✔
193
                'c' => return InterruptAction::Continue,
3✔
194
                's' => return InterruptAction::Stop,
7✔
195
                'a' => return InterruptAction::Abort,
2✔
196
                'r' => match self.collect_reply("Reply:", config.compose_in_editor, printer) {
16✔
197
                    ReplyResult::Reply(text) => return InterruptAction::Reply(text),
9✔
198
                    // Empty submit or `Ctrl+C` in a menu-driven reply re-shows
199
                    // the menu (the loop iterates).
200
                    ReplyResult::Empty | ReplyResult::Cancelled if menu => {}
5✔
201
                    // A configured (menu-less) reply has no menu to return to,
202
                    // so it mirrors the `'c'` branch: keep polling a live
203
                    // stream, otherwise continue from the partial response.
NEW
204
                    ReplyResult::Empty | ReplyResult::Cancelled if stream_alive => {
×
205
                        return InterruptAction::Resume;
1✔
206
                    }
207
                    ReplyResult::Empty | ReplyResult::Cancelled => {
208
                        return InterruptAction::Continue;
1✔
209
                    }
210
                },
NEW
211
                _ => unreachable!("unexpected interrupt choice"),
×
212
            }
213
        }
214
    }
26✔
215

216
    /// Handle an interrupt during tool execution.
217
    ///
218
    /// Presents a menu with options to stop & respond, restart, or continue
219
    /// waiting.
220
    /// Choosing "Stop & respond" collects a response: a typed message stops the
221
    /// tool and sends it, an empty submission stops with the canned default,
222
    /// and `Ctrl+C` backs out to the menu.
223
    ///
224
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
225
    /// the configured action runs directly without a menu.
226
    pub fn handle_tool_interrupt(
15✔
227
        &self,
15✔
228
        config: &ToolInterruptConfig,
15✔
229
        printer: &Printer,
15✔
230
    ) -> InterruptAction {
15✔
231
        let menu = config.action == ToolInterruptAction::Prompt;
15✔
232

233
        loop {
234
            let choice = match config.action {
16✔
235
                ToolInterruptAction::Prompt => {
236
                    let options = vec![
13✔
237
                        InlineOption::new('c', "Continue"),
13✔
238
                        InlineOption::new('r', "Stop & respond"),
13✔
239
                        InlineOption::new('t', "Restart"),
13✔
240
                    ];
241

242
                    self.backend
13✔
243
                        .inline_select("Interrupted", options, None, &mut printer.prompt_writer())
13✔
244
                        .unwrap_or('c')
13✔
245
                }
NEW
246
                ToolInterruptAction::Continue => 'c',
×
247
                ToolInterruptAction::Restart => 't',
1✔
248
                ToolInterruptAction::Respond => 'r',
2✔
249
            };
250

251
            match choice {
16✔
252
                'c' => return InterruptAction::Resume,
4✔
253
                't' => return InterruptAction::RestartTool,
3✔
254
                'r' => match self.collect_reply("Reply:", config.compose_in_editor, printer) {
9✔
255
                    ReplyResult::Reply(text) => {
3✔
256
                        return InterruptAction::ToolCancelled { response: text };
3✔
257
                    }
258
                    // `Ctrl+C` backs up to the menu (the loop iterates). A
259
                    // menu-less configured `respond` has no menu, so it falls
260
                    // through to the canned message below.
261
                    ReplyResult::Cancelled if menu => {}
1✔
262
                    // An empty submission stops the tool with the canned "no
263
                    // explanation" message; so does a menu-less `Ctrl+C`.
264
                    ReplyResult::Empty | ReplyResult::Cancelled => {
265
                        return InterruptAction::ToolCancelled {
5✔
266
                            response: DEFAULT_TOOL_CANCELLED_RESPONSE.to_owned(),
5✔
267
                        };
5✔
268
                    }
269
                },
NEW
270
                _ => unreachable!("unexpected interrupt choice"),
×
271
            }
272
        }
273
    }
15✔
274

275
    /// Collect a reply according to the `compose_in_editor` mode.
276
    ///
277
    /// - `false` / `"never"`: collect through the inline widget (the `Ctrl+X`
278
    ///   editor escape is wired only for `false`).
279
    /// - `true` / `"always"`: open the editor seeded empty.
280
    ///   A non-empty save is sent; an empty or cancelled editor returns to the
281
    ///   menu.
282
    ///   When the editor can't run, `true` falls back to the inline widget and
283
    ///   `"always"` returns to the menu (never the inline widget).
284
    fn collect_reply(
25✔
285
        &self,
25✔
286
        message: &str,
25✔
287
        compose: ComposeInEditor,
25✔
288
        printer: &Printer,
25✔
289
    ) -> ReplyResult {
25✔
290
        // Inline-first modes (`false` / `"never"`).
291
        if !compose.starts_in_editor() {
25✔
292
            return self.collect_reply_inline(message, compose.editor_escape(), printer);
20✔
293
        }
5✔
294

295
        // Editor-first modes (`true` / `"always"`): open the editor directly.
296
        let Some(editor) = self.editor.as_ref() else {
5✔
297
            // No editor configured: nothing to open.
298
            return self.editor_unavailable(message, compose, printer, None);
1✔
299
        };
300

301
        match editor.edit_text("") {
4✔
302
            Ok((EditOutcome::Saved, text)) if !text.trim().is_empty() => ReplyResult::Reply(text),
2✔
303
            // Empty save or a cancelled (non-zero-exit) editor: the user bailed,
304
            // so return to the menu.
305
            Ok(_) => ReplyResult::Cancelled,
1✔
306
            // The editor could not run.
307
            Err(error) => self.editor_unavailable(message, compose, printer, Some(error)),
2✔
308
        }
309
    }
25✔
310

311
    /// Handle an editor-first mode (`true` / `"always"`) when the editor can't
312
    /// be used — a spawn/I/O failure or no editor configured.
313
    ///
314
    /// `true` falls back to the inline widget so the user can still reply;
315
    /// `"always"` returns to the menu, never the inline widget (the user opted
316
    /// out of it).
317
    /// A spawn failure is surfaced on the chrome channel.
318
    fn editor_unavailable(
3✔
319
        &self,
3✔
320
        message: &str,
3✔
321
        compose: ComposeInEditor,
3✔
322
        printer: &Printer,
3✔
323
        error: Option<EditorError>,
3✔
324
    ) -> ReplyResult {
3✔
325
        if compose.falls_back_to_inline() {
3✔
326
            if let Some(error) = error {
2✔
327
                report_editor_failure(printer, &error, "Continuing with the inline editor.");
1✔
328
            }
1✔
329
            return self.collect_reply_inline(message, compose.editor_escape(), printer);
2✔
330
        }
1✔
331

332
        // `"always"`: never the inline widget.
333
        match error {
1✔
334
            Some(error) => report_editor_failure(printer, &error, "Returning to the menu."),
1✔
NEW
335
            None => printer.eprintln("\n⚠ No editor configured; returning to the menu."),
×
336
        }
337
        ReplyResult::Cancelled
1✔
338
    }
3✔
339

340
    /// Collect a reply through the inline widget.
341
    ///
342
    /// The `Ctrl+X` editor escape always returns to the inline prompt — it is
343
    /// never a terminal action.
344
    /// The only exits are a submission (empty or not) and `Ctrl+C` (or a prompt
345
    /// error), handled explicitly, never swallowed.
346
    fn collect_reply_inline(
22✔
347
        &self,
22✔
348
        message: &str,
22✔
349
        editor_escape: bool,
22✔
350
        printer: &Printer,
22✔
351
    ) -> ReplyResult {
22✔
352
        let mut buffer = String::new();
22✔
353
        loop {
354
            let output = printer.owned_prompt_writer();
25✔
355
            match self
25✔
356
                .backend
25✔
357
                .inline_reply(message, &buffer, self.edit_mode, editor_escape, output)
25✔
358
            {
359
                Ok(ReplyOutcome::OpenEditor { current_text }) => {
3✔
360
                    // The editor escape always returns here; whatever was typed
361
                    // before `Ctrl+X` is preserved.
362
                    buffer = current_text;
3✔
363
                    if let Some(editor) = self.editor.as_ref() {
3✔
364
                        match editor.edit_text(&buffer) {
3✔
365
                            // Re-seed with the editor's output, even if empty.
366
                            Ok((EditOutcome::Saved, edited)) => buffer = edited,
2✔
367
                            // Aborted editor: keep the buffer as it was.
NEW
368
                            Ok((EditOutcome::Cancelled, _)) => {}
×
369
                            // A spawn / I/O failure is surfaced (chrome +
370
                            // diagnostics); the buffer is kept.
371
                            Err(error) => {
1✔
372
                                report_editor_failure(printer, &error, "Keeping your text.");
1✔
373
                            }
1✔
374
                        }
NEW
375
                    }
×
376
                }
377
                Ok(ReplyOutcome::Submit(text)) if !text.trim().is_empty() => {
17✔
378
                    return ReplyResult::Reply(text);
11✔
379
                }
380
                // A blank (empty or whitespace-only) submission commits forward
381
                // with nothing; `Ctrl+C` or a prompt error backs up a level.
382
                // Whitespace counts as blank so the tool path reaches its canned
383
                // rejection rather than sending a blank-looking reply.
384
                Ok(ReplyOutcome::Submit(_)) => return ReplyResult::Empty,
6✔
385
                Ok(ReplyOutcome::Cancelled) | Err(_) => return ReplyResult::Cancelled,
5✔
386
            }
387
        }
388
    }
22✔
389
}
390

391
#[cfg(test)]
392
#[path = "handler_tests.rs"]
393
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