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

dcdpr / jp / 18011606263

25 Sep 2025 02:55PM UTC coverage: 45.74% (+1.2%) from 44.569%
18011606263

push

github

web-flow
feat(config, cli): Improved conversation configuration handling (#250)

New conversations now inherit the global configuration state, but after
that, further turns in the conversation will only take CLI configuration
into account (including any configuration files explicitly loaded via
`--cfg`). The configuration is stored in individual deltas that are
attached to individual conversation turns, and are merged with the
inherited configuration state.

To make this as convenient as possible, a few additional CLI flags have
been added to the query command to control the configuration state, such
as `--edit`, `--no-edit`, `--reasoning`, `--no-reasoning`, `--tool`, and
`--no-tool`.

The `--param` flag short option changes from `-r` to `-p` to accommodate
the new reasoning flags.

---------

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

492 of 901 new or added lines in 51 files covered. (54.61%)

13 existing lines in 8 files now uncovered.

6125 of 13391 relevant lines covered (45.74%)

5.85 hits per line

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

0.0
/crates/jp_cli/src/editor.rs
1
use std::{fs, path::PathBuf, str::FromStr};
2

3
use duct::Expression;
4
use jp_conversation::{ConversationId, UserMessage};
5
use time::{macros::format_description, UtcOffset};
6

7
use crate::{
8
    ctx::Ctx,
9
    error::{Error, Result},
10
};
11

12
/// The name of the file used to store the current query message.
13
const QUERY_FILENAME: &str = "QUERY_MESSAGE.md";
14

15
const CUT_MARKER: &[&str] = &[
16
    "---------------------------------------8<---------------------------------------",
17
    "--------------------- EVERYTHING BELOW THIS LINE IS IGNORED --------------------",
18
    "--------------------------------------->8---------------------------------------",
19
];
20

21
/// How to edit the query.
22
#[derive(Debug, Clone, PartialEq, Default)]
23
pub(crate) enum Editor {
24
    /// Use whatever editor is configured.
25
    #[default]
26
    Default,
27

28
    /// Use the given command.
29
    Command(String),
30

31
    /// Do not edit the query.
32
    Disabled,
33
}
34

35
impl FromStr for Editor {
36
    type Err = Error;
37

38
    fn from_str(s: &str) -> Result<Self> {
×
39
        match s {
×
40
            "true" => Ok(Self::Default),
×
41
            "false" => Ok(Self::Disabled),
×
NEW
42
            s => Ok(Self::Command(s.to_owned())),
×
43
        }
44
    }
×
45
}
46

47
/// Options for opening an editor.
48
#[derive(Debug)]
49
pub(crate) struct Options {
50
    cmd: Expression,
51

52
    /// The working directory to use.
53
    cwd: Option<PathBuf>,
54

55
    /// The initial content to use.
56
    content: Option<String>,
57
}
58

59
impl Options {
60
    pub(crate) fn new(cmd: Expression) -> Self {
×
61
        Self {
×
62
            cmd,
×
63
            cwd: None,
×
64
            content: None,
×
65
        }
×
66
    }
×
67

68
    /// Add a working directory to the editor options.
69
    #[must_use]
70
    pub(crate) fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
×
71
        self.cwd = Some(cwd.into());
×
72
        self
×
73
    }
×
74

75
    /// Add content to the editor options.
76
    #[must_use]
77
    pub(crate) fn with_content(mut self, content: impl Into<String>) -> Self {
×
78
        self.content = Some(content.into());
×
79
        self
×
80
    }
×
81
}
82

83
pub(crate) struct RevertFileGuard {
84
    path: Option<PathBuf>,
85
    orig: String,
86
    exists: bool,
87
}
88

89
impl RevertFileGuard {
90
    pub(crate) fn disarm(&mut self) {
×
91
        self.path.take();
×
92
    }
×
93
}
94

95
impl Drop for RevertFileGuard {
96
    fn drop(&mut self) {
×
97
        // No path, means this guard was disarmed.
98
        let Some(path) = &self.path else {
×
99
            return;
×
100
        };
101

102
        // File did not exist, so we remove it, and any empty parent
103
        // directories.
104
        if !self.exists {
×
105
            let _rm = fs::remove_file(path);
×
106
            let mut path = path.clone();
×
107
            loop {
108
                let Some(parent) = path.parent() else {
×
109
                    break;
×
110
                };
111

112
                let Ok(mut dir) = fs::read_dir(parent) else {
×
113
                    break;
×
114
                };
115

116
                if dir.next().is_some() {
×
117
                    break;
×
118
                }
×
119

120
                let _rm = fs::remove_dir(parent);
×
121
                path = parent.to_owned();
×
122
            }
123

124
            return;
×
125
        }
×
126

127
        // File existed, so we restore the original content.
128
        let _write = fs::write(path, &self.orig);
×
129
    }
×
130
}
131

132
/// Open an editor for the given file with the given content.
133
///
134
/// If the file exists, it will be opened, but the content will not be modified
135
/// (in other words, `content` is ignored).
136
///
137
/// When the editor is closed, the contents are returned.
138
pub(crate) fn open(path: PathBuf, options: Options) -> Result<(String, RevertFileGuard)> {
×
139
    let Options { cmd, cwd, content } = options;
×
140

141
    let exists = path.exists();
×
142
    let guard = RevertFileGuard {
×
143
        path: Some(path.clone()),
×
144
        orig: fs::read_to_string(&path).unwrap_or_default(),
×
145
        exists,
×
146
    };
×
147

148
    let existing_content = fs::read_to_string(&path).unwrap_or_default();
×
149

150
    if !exists || existing_content.is_empty() {
×
151
        if let Some(parent) = path.parent() {
×
152
            fs::create_dir_all(parent)?;
×
153
        }
×
154
        fs::write(&path, content.unwrap_or_default())?;
×
155
    }
×
156

157
    // Open the editor
158
    let output = cmd
×
159
        .before_spawn({
×
160
            let path = path.clone();
×
161
            move |cmd| {
×
162
                cmd.arg(path.clone());
×
163

164
                if let Some(cwd) = &cwd {
×
165
                    cmd.current_dir(cwd);
×
166
                }
×
167

168
                Ok(())
×
169
            }
×
170
        })
171
        .unchecked()
×
172
        .run()?;
×
173

174
    let status = output.status;
×
175
    if !status.success() {
×
176
        return Err(Error::Editor(format!("Editor exited with error: {status}")));
×
177
    }
×
178

179
    // Read the edited content
180
    let content = fs::read_to_string(path)?;
×
181

182
    Ok((content, guard))
×
183
}
×
184

185
/// Open an editor for the user to input or edit text using a file in the workspace
186
pub(crate) fn edit_query(
×
187
    ctx: &Ctx,
×
188
    conversation_id: ConversationId,
×
189
    initial_message: Option<String>,
×
190
    cmd: Expression,
×
191
) -> Result<(String, PathBuf)> {
×
192
    let root = ctx.workspace.storage_path().unwrap_or(&ctx.workspace.root);
×
193
    let history = ctx.workspace.get_messages(&conversation_id);
×
194

195
    let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
×
196
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
×
197

198
    let mut initial_text = vec![];
×
NEW
199
    for message in history.iter() {
×
200
        let mut buf = String::new();
×
201
        buf.push_str("# ");
×
202
        buf.push_str(
×
203
            &message
×
204
                .timestamp
×
205
                .to_offset(local_offset)
×
206
                .format(&format)
×
207
                .unwrap_or_else(|_| message.timestamp.to_string()),
×
208
        );
209
        buf.push_str("\n\n");
×
210

211
        let options = comrak::Options {
×
212
            render: comrak::RenderOptions {
×
213
                width: 80,
×
214
                unsafe_: true,
×
215
                prefer_fenced: true,
×
216
                experimental_minimize_commonmark: true,
×
217
                ..Default::default()
×
218
            },
×
219
            ..Default::default()
×
220
        };
×
221

222
        buf.push_str("## ASSISTANT\n\n");
×
223
        if let Some(reasoning) = &message.reply.reasoning {
×
224
            buf.push_str(&comrak::markdown_to_commonmark(
×
225
                &format!("### reasoning\n\n{reasoning}\n\n"),
×
226
                &options,
×
227
            ));
×
228
        }
×
229
        if let Some(content) = &message.reply.content {
×
230
            buf.push_str(&comrak::markdown_to_commonmark(
×
231
                &format!(
×
232
                    "{}{content}\n\n",
×
233
                    if message.reply.reasoning.is_some() {
×
234
                        "### response\n\n"
×
235
                    } else {
236
                        ""
×
237
                    }
238
                ),
239
                &options,
×
240
            ));
241
        }
×
242
        for tool_call in &message.reply.tool_calls {
×
243
            let Ok(result) = serde_json::to_string_pretty(&tool_call) else {
×
244
                continue;
×
245
            };
246

247
            buf.push_str("## TOOL CALL REQUEST\n\n");
×
248
            buf.push_str("```json\n");
×
249
            buf.push_str(&result);
×
250
            buf.push_str("\n```");
×
251
        }
252

253
        buf.push_str("\n\n");
×
254
        match &message.message {
×
255
            UserMessage::Query(query) => {
×
256
                buf.push_str("## YOU\n\n");
×
257
                buf.push_str(&comrak::markdown_to_commonmark(query, &options));
×
258
            }
×
259
            UserMessage::ToolCallResults(results) => {
×
260
                for result in results {
×
261
                    buf.push_str("## TOOL CALL RESULT\n\n");
×
262
                    buf.push_str("```\n");
×
263
                    buf.push_str(&result.content);
×
264
                    buf.push_str("\n```");
×
265
                }
×
266
            }
267
        }
268

269
        buf.push('\n');
×
270
        initial_text.push(buf);
×
271
    }
272

273
    initial_text.push(format!("model: {}\n", ctx.config().assistant.model.id));
×
274

275
    if !initial_text.is_empty() {
×
276
        let mut intro = String::new();
×
277
        intro.push_str("\n\n");
×
278
        intro.push_str(&CUT_MARKER.join("\n"));
×
279
        intro.push('\n');
×
280
        initial_text.push(intro);
×
281
    }
×
282

283
    if let Some(message) = initial_message {
×
284
        initial_text.push(message.trim_end().to_owned());
×
285
    }
×
286

287
    initial_text.reverse();
×
288

289
    let query_file_path = root.join(QUERY_FILENAME);
×
290

291
    let options = Options::new(cmd)
×
292
        .with_cwd(root)
×
293
        .with_content(initial_text.join("\n"));
×
294
    let (mut content, mut guard) = open(query_file_path.clone(), options)?;
×
295

296
    let eof = CUT_MARKER
×
297
        .iter()
×
298
        .filter_map(|marker| content.find(marker))
×
299
        .min()
×
300
        .unwrap_or(content.len());
×
301

302
    content.truncate(eof);
×
303
    content = content.trim().to_owned();
×
304

305
    // Disarm the guard, so the file is not reverted.
306
    guard.disarm();
×
307

308
    Ok((content, query_file_path))
×
309
}
×
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