﻿# A piped query opens the editor under --no-interactive

- **Status**: Done
- **Authors**: jp
- **Date**: 2026-09-18
- **Label**: client=cli
- **Label**: package=jp_cli
- **Label**: type=bug

`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

```sh
printf 'hello\n' | jp --no-interactive query --new -l > /tmp/out.log 2>&1
```

Expected: 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):

```rust
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:

```rust
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.
