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

dcdpr / jp / 27708733892

17 Jun 2026 05:50PM UTC coverage: 66.631% (+0.06%) from 66.574%
27708733892

Pull #758

github

web-flow
Merge 9e5296bf0 into 4778a5cd3
Pull Request #758: feat(config, cli, inquire): Configurable Ctrl-C interrupt behavior

110 of 122 new or added lines in 7 files covered. (90.16%)

2 existing lines in 2 files now uncovered.

35567 of 53379 relevant lines covered (66.63%)

403.16 hits per line

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

84.51
/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 {
26✔
101
        Self { backend }
26✔
102
    }
26✔
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 prompts for the reply text.
109
    pub fn handle_streaming_interrupt(
14✔
110
        &self,
14✔
111
        config: &StreamingInterruptConfig,
14✔
112
        writer: &mut dyn Write,
14✔
113
        stream_alive: bool,
14✔
114
    ) -> InterruptAction {
14✔
115
        let choice = match config.action {
14✔
116
            StreamingInterruptAction::Prompt => {
117
                let options = vec![
9✔
118
                    InlineOption::new('c', "Continue"),
9✔
119
                    InlineOption::new('r', "Reply (stop & respond)"),
9✔
120
                    InlineOption::new('s', "Stop (save & exit)"),
9✔
121
                    InlineOption::new('a', "Abort (discard & exit)"),
9✔
122
                ];
123

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

134
        match choice {
6✔
135
            'c' if stream_alive => InterruptAction::Resume,
3✔
136
            'c' => InterruptAction::Continue,
3✔
137
            'r' => InterruptAction::Reply(
3✔
138
                self.backend
3✔
139
                    .text_input("Reply:", writer)
3✔
140
                    .unwrap_or_default(),
3✔
141
            ),
3✔
142
            's' => InterruptAction::Stop,
3✔
143
            'a' => InterruptAction::Abort,
2✔
144
            _ => unreachable!("unexpected choice"),
×
145
        }
146
    }
14✔
147

148
    /// Handle an interrupt during tool execution.
149
    ///
150
    /// Presents a menu with options to stop & reply, restart, or continue
151
    /// waiting.
152
    /// When the user chooses "Stop & Reply", they can supply a custom message.
153
    /// An empty input produces a canned default.
154
    ///
155
    /// When `config.action` is `prompt` the interrupt menu is shown; otherwise
156
    /// the configured action runs directly without a menu.
157
    /// A `stop_reply` still prompts for the reply text.
158
    pub fn handle_tool_interrupt(
12✔
159
        &self,
12✔
160
        config: &ToolInterruptConfig,
12✔
161
        writer: &mut dyn Write,
12✔
162
    ) -> InterruptAction {
12✔
163
        let choice = match config.action {
12✔
164
            ToolInterruptAction::Prompt => {
165
                let options = vec![
10✔
166
                    InlineOption::new('c', "Continue"),
10✔
167
                    InlineOption::new('s', "Stop & Reply"),
10✔
168
                    InlineOption::new('r', "Restart"),
10✔
169
                ];
170

171
                self.backend
10✔
172
                    .inline_select("Interrupted", options, None, writer)
10✔
173
                    .unwrap_or('c')
10✔
174
            }
NEW
175
            ToolInterruptAction::Continue => 'c',
×
176
            ToolInterruptAction::Restart => 'r',
1✔
177
            ToolInterruptAction::StopReply => 's',
1✔
178
        };
179

180
        match choice {
12✔
181
            'c' => InterruptAction::Resume,
3✔
182
            's' => {
183
                let response = self
6✔
184
                    .backend
6✔
185
                    .text_input("Reply:", writer)
6✔
186
                    .unwrap_or_default();
6✔
187

188
                let response = if response.trim().is_empty() {
6✔
189
                    DEFAULT_TOOL_CANCELLED_RESPONSE.to_owned()
3✔
190
                } else {
191
                    response
3✔
192
                };
193

194
                InterruptAction::ToolCancelled { response }
6✔
195
            }
196
            'r' => InterruptAction::RestartTool,
3✔
197
            _ => unreachable!(),
×
198
        }
199
    }
12✔
200
}
201

202
#[cfg(test)]
203
#[path = "handler_tests.rs"]
204
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