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

dcdpr / jp / 27700893391

17 Jun 2026 03:37PM UTC coverage: 66.636% (+0.06%) from 66.574%
27700893391

Pull #758

github

web-flow
Merge 4bf89274d into 4778a5cd3
Pull Request #758: feat(config, cli): Add configurable Ctrl-C interrupt actions

139 of 157 new or added lines in 8 files covered. (88.54%)

2 existing lines in 2 files now uncovered.

35587 of 53405 relevant lines covered (66.64%)

449.5 hits per line

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

84.29
/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 (streaing vs tool
5
//! execution).
6
//!
7
//! The handler returns an [`InterruptAction`] that the caller can use to
8
//! determine the next step.
9
//!
10
//! ## Testing
11
//!
12
//! The handler uses dependency injection via [`PromptBackend`] to enable
13
//! testing without a real TTY.
14
//! In production, [`TerminalPromptBackend`] uses [`jp_inquire`].
15
//! In tests, [`MockPromptBackend`] provides pre-programmed responses.
16
//!
17
//! [`MockPromptBackend`]: jp_inquire::prompt::MockPromptBackend
18
//! [`TerminalPromptBackend`]: jp_inquire::prompt::TerminalPromptBackend
19

20
use std::io::Write;
21

22
use jp_config::interrupt::{
23
    StreamingInterruptAction, StreamingInterruptConfig, ToolInterruptAction, ToolInterruptConfig,
24
};
25
use jp_inquire::{
26
    InlineOption,
27
    prompt::{PromptBackend, TerminalPromptBackend},
28
};
29

30
/// Default response sent to the LLM when the user cancels a tool without
31
/// supplying a custom message.
32
const DEFAULT_TOOL_CANCELLED_RESPONSE: &str = indoc::concatdoc! {"
33
    This tool request was intentionally rejected by the user. \
34
    Please evaluate and either ask the user why it was rejected, \
35
    or infer the reason by looking at the historical messages \
36
    in the conversation.\
37
"};
38

39
/// Actions that can be taken after an interrupt.
40
#[derive(Debug, Clone, PartialEq, Eq)]
41
pub enum InterruptAction {
42
    /// Stop generation gracefully.
43
    Stop,
44

45
    /// Abort generation, without saving the current cycle.
46
    Abort,
47

48
    /// Stop generation and immediately reply with a new user message.
49
    Reply(String),
50

51
    /// Resume generation (if stream is alive) or wait (if tool is running).
52
    Resume,
53

54
    /// Continue generation from partial content using assistant prefill.
55
    ///
56
    /// When the stream has died (e.g., due to timeout), we can inject the
57
    /// partial content as an assistant message and ask the LLM to continue from
58
    /// there.
59
    Continue,
60

61
    /// Cancel all running tools and restart the entire batch.
62
    RestartTool,
63

64
    /// Cancel all running tools and return a user-supplied response to the LLM.
65
    ///
66
    /// If the user leaves the response empty, a canned message is used that
67
    /// instructs the LLM to evaluate why the tool was rejected.
68
    ToolCancelled { response: String },
69
}
70

71
/// Handles user interrupts (Ctrl+C) during query execution.
72
///
73
/// This handler presents interactive menus and returns the user's chosen
74
/// action.
75
/// The actual handling of the action is done by the caller.
76
///
77
/// Uses [`PromptBackend`] for dependency injection, enabling testing without a
78
/// TTY.
79
pub struct InterruptHandler<P: PromptBackend = TerminalPromptBackend> {
80
    backend: P,
81
}
82

83
impl Default for InterruptHandler<TerminalPromptBackend> {
84
    fn default() -> Self {
×
85
        Self::new()
×
86
    }
×
87
}
88

89
impl InterruptHandler<TerminalPromptBackend> {
90
    /// Create a new interrupt handler.
91
    pub fn new() -> Self {
×
92
        Self {
×
93
            backend: TerminalPromptBackend,
×
94
        }
×
95
    }
×
96
}
97

98
impl<P: PromptBackend> InterruptHandler<P> {
99
    /// Create an interrupt handler with a custom prompt backend.
100
    pub fn with_backend(backend: P) -> Self {
28✔
101
        Self { backend }
28✔
102
    }
28✔
103

104
    /// Handle an interrupt during LLM streaming.
105
    ///
106
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
107
    /// the configured action runs directly without a menu.
108
    /// A reply still collects text — via the editor when
109
    /// `config.reply_in_editor` is set, otherwise via the inline prompt.
110
    pub fn handle_streaming_interrupt(
15✔
111
        &self,
15✔
112
        config: &StreamingInterruptConfig,
15✔
113
        writer: &mut dyn Write,
15✔
114
        stream_alive: bool,
15✔
115
    ) -> InterruptAction {
15✔
116
        let choice = match config.action {
15✔
117
            StreamingInterruptAction::Prompt => {
118
                let options = vec![
9✔
119
                    InlineOption::new('c', "Continue"),
9✔
120
                    InlineOption::new('r', "Reply (stop & respond)"),
9✔
121
                    InlineOption::new('s', "Stop (save & exit)"),
9✔
122
                    InlineOption::new('a', "Abort (discard & exit)"),
9✔
123
                ];
124

125
                self.backend
9✔
126
                    .inline_select("Interrupted", options, None, writer)
9✔
127
                    .unwrap_or('s')
9✔
128
            }
129
            StreamingInterruptAction::Continue => 'c',
2✔
130
            StreamingInterruptAction::Reply => 'r',
2✔
131
            StreamingInterruptAction::Stop => 's',
1✔
132
            StreamingInterruptAction::Abort => 'a',
1✔
133
        };
134

135
        match choice {
6✔
136
            'c' if stream_alive => InterruptAction::Resume,
3✔
137
            'c' => InterruptAction::Continue,
3✔
138
            'r' => InterruptAction::Reply(self.reply_text(config.reply_in_editor, writer)),
4✔
139
            's' => InterruptAction::Stop,
3✔
140
            'a' => InterruptAction::Abort,
2✔
141
            _ => unreachable!("unexpected choice"),
×
142
        }
143
    }
15✔
144

145
    /// Collect the reply text for an interrupt reply.
146
    ///
147
    /// Opens the editor directly when `in_editor` is set; otherwise shows the
148
    /// inline text prompt.
149
    /// An empty string is returned when input is cancelled.
150
    fn reply_text(&self, in_editor: bool, writer: &mut dyn Write) -> String {
11✔
151
        let result = if in_editor {
11✔
152
            self.backend.editor_input()
2✔
153
        } else {
154
            self.backend.text_input("Reply:", writer)
9✔
155
        };
156

157
        result.unwrap_or_default()
11✔
158
    }
11✔
159

160
    /// Handle an interrupt during tool execution.
161
    ///
162
    /// Presents a menu with options to stop & reply, restart, or continue
163
    /// waiting.
164
    /// When the user chooses "Stop & Reply", they can supply a custom message.
165
    /// An empty input produces a canned default.
166
    ///
167
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
168
    /// the configured action runs directly without a menu.
169
    /// A reply still collects text — via the editor when
170
    /// `config.reply_in_editor` is set, otherwise via the inline prompt.
171
    pub fn handle_tool_interrupt(
13✔
172
        &self,
13✔
173
        config: &ToolInterruptConfig,
13✔
174
        writer: &mut dyn Write,
13✔
175
    ) -> InterruptAction {
13✔
176
        let choice = match config.action {
13✔
177
            ToolInterruptAction::Prompt => {
178
                let options = vec![
10✔
179
                    InlineOption::new('c', "Continue"),
10✔
180
                    InlineOption::new('s', "Stop & Reply"),
10✔
181
                    InlineOption::new('r', "Restart"),
10✔
182
                ];
183

184
                self.backend
10✔
185
                    .inline_select("Interrupted", options, None, writer)
10✔
186
                    .unwrap_or('c')
10✔
187
            }
NEW
188
            ToolInterruptAction::Continue => 'c',
×
189
            ToolInterruptAction::Restart => 'r',
1✔
190
            ToolInterruptAction::StopReply => 's',
2✔
191
        };
192

193
        match choice {
13✔
194
            'c' => InterruptAction::Resume,
3✔
195
            's' => {
196
                let response = self.reply_text(config.reply_in_editor, writer);
7✔
197

198
                let response = if response.trim().is_empty() {
7✔
199
                    DEFAULT_TOOL_CANCELLED_RESPONSE.to_owned()
3✔
200
                } else {
201
                    response
4✔
202
                };
203

204
                InterruptAction::ToolCancelled { response }
7✔
205
            }
206
            'r' => InterruptAction::RestartTool,
3✔
207
            _ => unreachable!(),
×
208
        }
209
    }
13✔
210
}
211

212
#[cfg(test)]
213
#[path = "handler_tests.rs"]
214
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