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

dcdpr / jp / 26664039893

29 May 2026 09:50PM UTC coverage: 66.375% (+0.003%) from 66.372%
26664039893

push

github

web-flow
chore: reformat all markdown files using `comfort` (#699)

Signed-off-by: Jean Mertz <git@jeanmertz.com>

32028 of 48253 relevant lines covered (66.38%)

269.79 hits per line

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

83.05
/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_inquire::{
23
    InlineOption,
24
    prompt::{PromptBackend, TerminalPromptBackend},
25
};
26

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

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

42
    /// Abort generation, without saving the current cycle.
43
    Abort,
44

45
    /// Stop generation and immediately reply with a new user message.
46
    Reply(String),
47

48
    /// Resume generation (if stream is alive) or wait (if tool is running).
49
    Resume,
50

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

58
    /// Cancel all running tools and restart the entire batch.
59
    RestartTool,
60

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

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

80
impl Default for InterruptHandler<TerminalPromptBackend> {
81
    fn default() -> Self {
×
82
        Self::new()
×
83
    }
×
84
}
85

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

95
impl<P: PromptBackend> InterruptHandler<P> {
96
    /// Create an interrupt handler with a custom prompt backend.
97
    pub fn with_backend(backend: P) -> Self {
19✔
98
        Self { backend }
19✔
99
    }
19✔
100

101
    /// Handle an interrupt during LLM streaming.
102
    pub fn handle_streaming_interrupt(
9✔
103
        &self,
9✔
104
        writer: &mut dyn Write,
9✔
105
        stream_alive: bool,
9✔
106
    ) -> InterruptAction {
9✔
107
        let options = vec![
9✔
108
            InlineOption::new('c', "Continue"),
9✔
109
            InlineOption::new('r', "Reply (discard & reply)"),
9✔
110
            InlineOption::new('s', "Stop (save & exit)"),
9✔
111
            InlineOption::new('a', "Abort (discard & exit)"),
9✔
112
        ];
113

114
        let choice = self
9✔
115
            .backend
9✔
116
            .inline_select("Interrupted", options, None, writer)
9✔
117
            .unwrap_or('s');
9✔
118

119
        match choice {
4✔
120
            'c' if stream_alive => InterruptAction::Resume,
2✔
121
            'c' => InterruptAction::Continue,
2✔
122
            'r' => InterruptAction::Reply(
2✔
123
                self.backend
2✔
124
                    .text_input("Reply:", writer)
2✔
125
                    .unwrap_or_default(),
2✔
126
            ),
2✔
127
            's' => InterruptAction::Stop,
2✔
128
            'a' => InterruptAction::Abort,
1✔
129
            _ => unreachable!("unexpected choice"),
×
130
        }
131
    }
9✔
132

133
    /// Handle an interrupt during tool execution.
134
    ///
135
    /// Presents a menu with options to stop & reply, restart, or continue
136
    /// waiting.
137
    /// When the user chooses "Stop & Reply", they can supply a custom message.
138
    /// An empty input produces a canned default.
139
    pub fn handle_tool_interrupt(&self, writer: &mut dyn Write) -> InterruptAction {
10✔
140
        let options = vec![
10✔
141
            InlineOption::new('c', "Continue"),
10✔
142
            InlineOption::new('s', "Stop & Reply"),
10✔
143
            InlineOption::new('r', "Restart"),
10✔
144
        ];
145

146
        let choice = self
10✔
147
            .backend
10✔
148
            .inline_select("Interrupted", options, None, writer)
10✔
149
            .unwrap_or('c');
10✔
150

151
        match choice {
10✔
152
            'c' => InterruptAction::Resume,
3✔
153
            's' => {
154
                let response = self
5✔
155
                    .backend
5✔
156
                    .text_input("Reply:", writer)
5✔
157
                    .unwrap_or_default();
5✔
158

159
                let response = if response.trim().is_empty() {
5✔
160
                    DEFAULT_TOOL_CANCELLED_RESPONSE.to_owned()
3✔
161
                } else {
162
                    response
2✔
163
                };
164

165
                InterruptAction::ToolCancelled { response }
5✔
166
            }
167
            'r' => InterruptAction::RestartTool,
2✔
168
            _ => unreachable!(),
×
169
        }
170
    }
10✔
171
}
172

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

© 2026 Coveralls, Inc