A piped query opens the editor under --no-interactive
jp query reads a query from piped stdin and then opens the editor to compose it, even when --no-interactive is set. In an environment with no terminal the editor blocks forever, and the run hangs until something kills it.
Reproduction
printf 'hello\n' | jp --no-interactive query --new -l > /tmp/out.log 2>&1Expected: the turn runs with hello as the query.
Observed: $EDITOR is spawned on the conversation's QUERY_MESSAGE.md and the process sits until killed. /tmp/out.log stays empty, so nothing indicates what the run is waiting for.
Seen while driving jp from an unattended shell loop with stdout redirected to a file. The symptom at the call site is a jp process with no output and essentially no CPU time, plus an editor process nobody asked for.
Cause
Query::edit_message skips the editor when any of three conditions hold (crates/jp_cli/src/cmd/query.rs, around line 1003):
if (self.input.query.as_ref().is_some_and(|v| !v.is_empty())
|| !piped
|| self.force_no_edit())
&& !self.force_edit()
&& !request.is_empty()
{
return Ok((source, PartialAppConfig::empty()));
}With the query on stdin and no positional argument, the first clause is false and !piped is false. The third is force_no_edit(), which is:
fn force_no_edit(&self) -> bool {
self.no_edit
}It reads the --no-edit flag and nothing else, so a caller that declared the run non-interactive still lands in the editor branch. Seeding the editor from piped stdin is the intended compose flow; doing it when nobody is present to close the editor is not.
Suggested fix
Have force_no_edit() also return true when the invocation is non-interactive, so --no-interactive and JP_NONINTERACTIVE=1 suppress the editor the way --no-edit does. Term::interactive already carries the signal.
--edit passed explicitly alongside --no-interactive is a contradiction worth rejecting with a diagnostic rather than resolving silently either way.
Test
An integration test that pipes a query with --no-interactive and an editor.cmd pointing at a command that would fail or hang if invoked, asserting the turn completes and the editor command never ran. Asserting only that the run succeeds would pass if the editor were spawned and happened to exit zero.
Note
--no-interactive's own help text names "a script or a CI job" as the case it exists for, which is exactly the case that hangs.
Comments
- From: jp
- Date: 2026-09-18T15:10:12Z
Fixed by gating the editor on interactivity at the point where the backend is resolved, rather than inside force_no_edit().
force_no_edit() also drives the branch above it, which synthesizes a continue message when the request is empty. Making that branch fire under --no-interactive would have turned jp query with no input into a silent request to the provider, replacing a hang with a surprise API call. Gating at the backend instead keeps the two cases apart:
- Request has content (piped or inline): the editor is skipped and the text is sent as-is. This is the reported case.
- Request is empty:
Error::NonInteractiveEditor, which names the reason and suggests passing the query as an argument or on stdin, or using--no-edit.
Not done: rejecting an explicit --edit alongside --no-interactive. It is now ignored rather than honoured, which is the safe direction but still silent. Worth a separate ticket if it bites.
- From: jp
- Date: 2026-10-06T09:41:04Z
- Re: #1
Correction to the last paragraph: an editor-forcing flag under --no-interactive is rejected, not ignored.
Ignoring it was not the safe direction after all. --quote seeds the request with the assistant's last message before edit_message runs, so on a conversation with any prior response the request is non-empty by the time the backend resolves. Skipping the editor there sent the assistant's own words back as the user's turn, called the provider, and persisted the exchange — and stdout being redirected is enough to trigger it, no --no-interactive needed.
Query::edit_message now matches Some(_) if self.force_edit() ahead of the non-empty-request fallback and returns Error::NonInteractiveEditor suggesting a terminal or --no-edit. The guard sits inside the Some(_) path, so a run with no editor configured still falls back as it always has.
force_edit() covers --edit and --quote together, so the contradiction the description asked to reject is rejected too.