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

dcdpr / jp / 19034261978

03 Nov 2025 12:12PM UTC coverage: 48.957% (+0.9%) from 48.069%
19034261978

push

github

web-flow
feat(cli, editor): Allow editing configuration in query editor (#310)

Users can now modify the active configuration directly within the
`QUERY_MESSAGE.md` editor when using `jp query`. The editor displays a
subset of the current configuration in a TOML code block below a cut
marker, which can be edited (and amended) before submitting the query.
Invalid configuration will cause the editor to reopen with an error
message, allowing immediate correction.

Currently, the configuration section shows the model ID and reasoning
parameters, but any valid configuration properties can be set in this
code block. Changes made in the editor are merged with the existing
configuration and stored with the conversation message, eliminating the
need to close the editor and restart with different CLI arguments.

The editor also shows an improved conversation history section with
timestamps, model information, and properly formatted markdown for
assistant responses including reasoning content when available.

Implementation adds a new `QueryDocument` parser in
`crates/jp_cli/src/editor/parser.rs` that handles the structured format
of the query file, separating the query content from metadata sections.
The `edit_query` function now returns both the query text and any
configuration changes, which are merged into the partial config before
storing messages. Error handling includes validation feedback displayed
directly in the editor to streamline the correction workflow.

Related: #217

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

404 of 602 new or added lines in 6 files covered. (67.11%)

4 existing lines in 2 files now uncovered.

8119 of 16584 relevant lines covered (48.96%)

14.08 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
mod parser;
2

3
use std::{
4
    fs::{self, OpenOptions},
5
    io::{Read as _, Write as _},
6
    path::PathBuf,
7
    str::FromStr,
8
};
9

10
use duct::Expression;
11
use itertools::Itertools;
12
use jp_config::{
13
    AppConfig, Config as _, PartialAppConfig, ToPartial as _,
14
    model::parameters::PartialReasoningConfig,
15
};
16
use jp_conversation::{ConversationId, UserMessage, message::Messages};
17
use time::{UtcOffset, macros::format_description};
18

19
use crate::{
20
    ctx::Ctx,
21
    editor::parser::QueryDocument,
22
    error::{Error, Result},
23
};
24

25
/// The name of the file used to store the current query message.
26
const QUERY_FILENAME: &str = "QUERY_MESSAGE.md";
27

28
/// How to edit the query.
29
#[derive(Debug, Clone, PartialEq, Default)]
30
pub(crate) enum Editor {
31
    /// Use whatever editor is configured.
32
    #[default]
33
    Default,
34

35
    /// Use the given command.
36
    Command(String),
37

38
    /// Do not edit the query.
39
    Disabled,
40
}
41

42
impl FromStr for Editor {
43
    type Err = Error;
44

45
    fn from_str(s: &str) -> Result<Self> {
×
46
        match s {
×
47
            "true" => Ok(Self::Default),
×
48
            "false" => Ok(Self::Disabled),
×
49
            s => Ok(Self::Command(s.to_owned())),
×
50
        }
51
    }
×
52
}
53

54
/// Options for opening an editor.
55
#[derive(Debug)]
56
pub(crate) struct Options {
57
    cmd: Expression,
58

59
    /// The working directory to use.
60
    cwd: Option<PathBuf>,
61

62
    /// The initial content to use.
63
    content: Option<String>,
64

65
    /// Whether to force write the file, even if it already exists.
66
    force_write: bool,
67
}
68

69
impl Options {
70
    pub(crate) fn new(cmd: Expression) -> Self {
×
71
        Self {
×
72
            cmd,
×
73
            cwd: None,
×
74
            content: None,
×
NEW
75
            force_write: false,
×
76
        }
×
77
    }
×
78

79
    /// Add a working directory to the editor options.
80
    #[must_use]
81
    pub(crate) fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
×
82
        self.cwd = Some(cwd.into());
×
83
        self
×
84
    }
×
85

86
    /// Add content to the editor options.
87
    #[must_use]
88
    pub(crate) fn with_content(mut self, content: impl Into<String>) -> Self {
×
89
        self.content = Some(content.into());
×
90
        self
×
91
    }
×
92

93
    /// Force write the file, even if it already exists.
94
    #[must_use]
NEW
95
    pub(crate) fn with_force_write(mut self, force_write: bool) -> Self {
×
NEW
96
        self.force_write = force_write;
×
NEW
97
        self
×
NEW
98
    }
×
99
}
100

101
pub(crate) struct RevertFileGuard {
102
    path: Option<PathBuf>,
103
    orig: String,
104
    exists: bool,
105
}
106

107
impl RevertFileGuard {
108
    pub(crate) fn disarm(&mut self) {
×
109
        self.path.take();
×
110
    }
×
111
}
112

113
impl Drop for RevertFileGuard {
114
    fn drop(&mut self) {
×
115
        // No path, means this guard was disarmed.
116
        let Some(path) = &self.path else {
×
117
            return;
×
118
        };
119

120
        // File did not exist, so we remove it, and any empty parent
121
        // directories.
122
        if !self.exists {
×
123
            let _rm = fs::remove_file(path);
×
124
            let mut path = path.clone();
×
125
            loop {
126
                let Some(parent) = path.parent() else {
×
127
                    break;
×
128
                };
129

130
                let Ok(mut dir) = fs::read_dir(parent) else {
×
131
                    break;
×
132
                };
133

134
                if dir.next().is_some() {
×
135
                    break;
×
136
                }
×
137

138
                let _rm = fs::remove_dir(parent);
×
139
                path = parent.to_owned();
×
140
            }
141

142
            return;
×
143
        }
×
144

145
        // File existed, so we restore the original content.
146
        let _write = fs::write(path, &self.orig);
×
147
    }
×
148
}
149

150
/// Open an editor for the given file with the given content.
151
///
152
/// If the file exists, it will be opened, but the content will not be modified
153
/// (in other words, `content` is ignored).
154
///
155
/// When the editor is closed, the contents are returned.
156
pub(crate) fn open(path: PathBuf, options: Options) -> Result<(String, RevertFileGuard)> {
×
157
    let Options {
NEW
158
        cmd,
×
NEW
159
        cwd,
×
NEW
160
        content,
×
NEW
161
        force_write,
×
NEW
162
    } = options;
×
163

164
    let exists = path.exists();
×
165
    let guard = RevertFileGuard {
×
166
        path: Some(path.clone()),
×
167
        orig: fs::read_to_string(&path).unwrap_or_default(),
×
168
        exists,
×
169
    };
×
170

171
    let existing_content = fs::read_to_string(&path).unwrap_or_default();
×
172

NEW
173
    if !exists || existing_content.is_empty() || force_write {
×
174
        if let Some(parent) = path.parent() {
×
175
            fs::create_dir_all(parent)?;
×
176
        }
×
177

NEW
178
        let mut file = OpenOptions::new()
×
NEW
179
            .read(true)
×
NEW
180
            .write(true)
×
NEW
181
            .create(true)
×
NEW
182
            .truncate(true)
×
NEW
183
            .open(&path)?;
×
184

NEW
185
        let mut current_content = String::new();
×
NEW
186
        file.read_to_string(&mut current_content)?;
×
187

NEW
188
        file.write_all(content.unwrap_or_default().as_bytes())?;
×
NEW
189
        file.write_all(current_content.as_bytes())?;
×
UNCOV
190
    }
×
191

192
    // Open the editor
193
    let output = cmd
×
194
        .before_spawn({
×
195
            let path = path.clone();
×
196
            move |cmd| {
×
197
                cmd.arg(path.clone());
×
198

199
                if let Some(cwd) = &cwd {
×
200
                    cmd.current_dir(cwd);
×
201
                }
×
202

203
                Ok(())
×
204
            }
×
205
        })
206
        .unchecked()
×
207
        .run()?;
×
208

209
    let status = output.status;
×
210
    if !status.success() {
×
211
        return Err(Error::Editor(format!("Editor exited with error: {status}")));
×
212
    }
×
213

214
    // Read the edited content
215
    let content = fs::read_to_string(path)?;
×
216

217
    Ok((content, guard))
×
218
}
×
219

220
/// Open an editor for the user to input or edit text using a file in the workspace
221
pub(crate) fn edit_query(
×
NEW
222
    ctx: &mut Ctx,
×
223
    conversation_id: &ConversationId,
×
NEW
224
    query: Option<&str>,
×
225
    cmd: Expression,
×
NEW
226
    config_error: Option<&str>,
×
NEW
227
) -> Result<(String, PathBuf, PartialAppConfig)> {
×
228
    let root = ctx.workspace.storage_path().unwrap_or(&ctx.workspace.root);
×
NEW
229
    let history = ctx.workspace.get_messages(conversation_id).to_messages();
×
NEW
230
    let query_file_path = root.join(QUERY_FILENAME);
×
231

NEW
232
    let existing_content = fs::read_to_string(&query_file_path).unwrap_or_default();
×
NEW
233
    let mut doc = QueryDocument::try_from(existing_content.as_str()).unwrap_or_default();
×
234

NEW
235
    if let Some(v) = query
×
NEW
236
        && doc.query.is_empty()
×
NEW
237
    {
×
NEW
238
        doc.query = v;
×
NEW
239
    }
×
240

NEW
241
    let config_value = build_config_text(ctx.config());
×
NEW
242
    if doc.meta.config.value.is_empty() {
×
NEW
243
        doc.meta.config.value = &config_value;
×
NEW
244
    }
×
245

NEW
246
    if let Some(error) = config_error {
×
NEW
247
        doc.meta.config.error = Some(error);
×
NEW
248
    }
×
249

NEW
250
    let history_value = build_history_text(history);
×
NEW
251
    doc.meta.history.value = &history_value;
×
252

NEW
253
    let options = Options::new(cmd.clone())
×
NEW
254
        .with_cwd(root)
×
NEW
255
        .with_content(doc)
×
NEW
256
        .with_force_write(true);
×
257

NEW
258
    let (content, mut guard) = open(query_file_path.clone(), options)?;
×
259

NEW
260
    let doc = QueryDocument::try_from(content.as_str()).unwrap_or_default();
×
NEW
261
    let mut config = PartialAppConfig::empty();
×
NEW
262
    if !doc.meta.config.value.is_empty() {
×
NEW
263
        match toml::from_str::<PartialAppConfig>(doc.meta.config.value) {
×
NEW
264
            Ok(v) => config = v,
×
NEW
265
            Err(error) => {
×
NEW
266
                let error = error.to_string();
×
NEW
267
                return edit_query(ctx, conversation_id, None, cmd, Some(&error));
×
268
            }
269
        }
NEW
270
    }
×
271

NEW
272
    guard.disarm();
×
NEW
273
    Ok((doc.query.to_owned(), query_file_path, config))
×
NEW
274
}
×
275

NEW
276
fn build_config_text(config: &AppConfig) -> String {
×
NEW
277
    let model_id = &config.assistant.model.id;
×
NEW
278
    let mut tools = config
×
NEW
279
        .conversation
×
NEW
280
        .tools
×
NEW
281
        .iter()
×
NEW
282
        .filter_map(|(k, cfg)| cfg.enable().then_some(k))
×
NEW
283
        .sorted()
×
NEW
284
        .collect::<Vec<_>>()
×
NEW
285
        .join(", ");
×
286

NEW
287
    if tools.is_empty() {
×
NEW
288
        tools = "(none)".to_owned();
×
NEW
289
    }
×
290

NEW
291
    let mut active_config = PartialAppConfig::empty();
×
NEW
292
    active_config.assistant.model.id = model_id.to_partial();
×
NEW
293
    active_config.assistant.model.parameters.reasoning = config
×
NEW
294
        .assistant
×
NEW
295
        .model
×
NEW
296
        .parameters
×
NEW
297
        .reasoning
×
NEW
298
        .map(|v| v.to_partial())
×
NEW
299
        .or(Some(PartialReasoningConfig::Auto));
×
300

NEW
301
    toml::to_string_pretty(&active_config).unwrap_or_default()
×
NEW
302
}
×
303

NEW
304
fn build_history_text(mut history: Messages) -> String {
×
NEW
305
    let mut text = String::new();
×
306

NEW
307
    if !history.is_empty() {
×
NEW
308
        text.push_str("\n# Conversation History");
×
NEW
309
    }
×
310

311
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
×
NEW
312
    let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
×
313

NEW
314
    let mut messages_with_config = vec![];
×
315
    loop {
NEW
316
        let partial = history.config();
×
NEW
317
        let config = AppConfig::from_partial(partial).ok();
×
NEW
318
        let Some(message) = history.pop() else {
×
NEW
319
            break;
×
320
        };
321

NEW
322
        messages_with_config.push((message, config));
×
323
    }
324

NEW
325
    let mut messages = vec![];
×
NEW
326
    for (message, config) in messages_with_config {
×
327
        let mut buf = String::new();
×
NEW
328
        let timestamp = message
×
NEW
329
            .timestamp
×
NEW
330
            .to_offset(local_offset)
×
NEW
331
            .format(&format)
×
NEW
332
            .unwrap_or_else(|_| message.timestamp.to_string());
×
333

334
        let options = comrak::Options {
×
335
            render: comrak::RenderOptions {
×
336
                width: 80,
×
337
                unsafe_: true,
×
338
                prefer_fenced: true,
×
339
                experimental_minimize_commonmark: true,
×
340
                ..Default::default()
×
341
            },
×
342
            ..Default::default()
×
343
        };
×
344

NEW
345
        buf.push_str("\n\n## Assistant");
×
346

NEW
347
        if let Some(cfg) = config {
×
NEW
348
            buf.push_str(&format!(" ({})", cfg.assistant.model.id));
×
NEW
349
        }
×
350

NEW
351
        buf.push_str(&format!(" on {timestamp}"));
×
352

353
        if let Some(reasoning) = &message.reply.reasoning {
×
354
            buf.push_str(&comrak::markdown_to_commonmark(
×
NEW
355
                &format!("\n\n### reasoning\n\n{reasoning}"),
×
356
                &options,
×
357
            ));
×
358
        }
×
359

360
        if let Some(content) = &message.reply.content {
×
NEW
361
            buf.push_str("\n\n");
×
NEW
362
            buf.push_str(
×
NEW
363
                comrak::markdown_to_commonmark(
×
NEW
364
                    &format!(
×
NEW
365
                        "{}{content}",
×
NEW
366
                        if message.reply.reasoning.is_some() {
×
NEW
367
                            "### response\n\n"
×
368
                        } else {
NEW
369
                            ""
×
370
                        }
371
                    ),
NEW
372
                    &options,
×
373
                )
NEW
374
                .trim(),
×
375
            );
376
        }
×
377

378
        for tool_call in &message.reply.tool_calls {
×
379
            let Ok(result) = serde_json::to_string_pretty(&tool_call) else {
×
380
                continue;
×
381
            };
382

383
            buf.push_str("## TOOL CALL REQUEST\n\n");
×
384
            buf.push_str("```json\n");
×
385
            buf.push_str(&result);
×
386
            buf.push_str("\n```");
×
387
        }
388

389
        buf.push_str("\n\n");
×
390
        match &message.message {
×
391
            UserMessage::Query(query) => {
×
NEW
392
                buf.push_str("## You\n\n");
×
NEW
393
                buf.push_str(comrak::markdown_to_commonmark(query, &options).trim());
×
394
            }
×
395
            UserMessage::ToolCallResults(results) => {
×
396
                for result in results {
×
397
                    buf.push_str("## TOOL CALL RESULT\n\n");
×
398
                    buf.push_str("```\n");
×
399
                    buf.push_str(&result.content);
×
400
                    buf.push_str("\n```");
×
401
                }
×
402
            }
403
        }
404

405
        buf.push('\n');
×
NEW
406
        messages.push(buf);
×
407
    }
408

NEW
409
    messages.reverse();
×
NEW
410
    text.extend(messages);
×
NEW
411
    text
×
UNCOV
412
}
×
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