RFD 104: Terminal Capability and Promptability Signals
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-09-04
- Extends: RFD 048
- Required by: RFD 049, RFD 091
Summary
JP answers four different questions with one boolean: whether stdout is a terminal. This RFD names the four questions, gives each its own source, moves channel capability onto Printer where the channels already live, and completes the --interactive / --no-interactive pair so a caller can both override and assert promptability.
Motivation
jp conversation ls | rg fix stopped and asked which workspace to use (#1068). The prompt came from the workspace precedence ladder (RFD 087), which runs whenever stdin is a terminal — and piping stdout does not change stdin.
The reporter read that as "commands in a pipe must not ask". This RFD does not adopt that rule: a piped command still has a user behind it, and jp query | less may legitimately prompt. What the report exposes is narrower and worse — JP had no way to say "do not ask me", and the signal it did consult had nothing to do with whether anyone was there.
That report is one symptom of a structural problem. JP has, at various points, used three differently-derived signals to answer questions that are not the same question:
| Signal | Derivation | Consulted by |
|---|---|---|
Term::is_tty | stdout.is_terminal() | Tool permissions, editor confirmations, lock |
| timeout, plugin approval, output format, spinners | ||
TargetEnv::interactive | stdin.is_terminal() | The workspace ladder and pickers |
Sink construction | --format is pretty | ANSI stripping on both channels |
The failures follow from the mismatch, not from any one of them being wrong in isolation:
- A piped listing prompts. stdout is not a terminal, so the
is_ttyconsumers go quiet; stdin still is, so the ladder asks. #1068. - A repaint accumulates.
jp query | lessresolves--formattotextfrom stdout, so theSinkstrips\r\x1b[Kentirely —StripSink::executekeeps line feeds and drops every other C0 control, carriage return included. stderr is still the user's terminal, so instead of one retry line updating in place they get a new line per attempt. - A log file collects escapes.
jp query 2> log.txthas stdout on a terminal, so--formatresolves totext-prettyand cursor control is written into the file verbatim.
RFD 048 already decided the first of these:
stdout.is_terminal()→ can the consumer handle ANSI escape codes?/dev/ttyavailable → can a user answer prompts?
The decision was merged; the code never implemented it. jp_printer::open_tty opens /dev/tty, but only to give prompts somewhere to draw, and prompt_writer falls back to out when the open fails — so the availability that RFD 048 designates as the promptability signal is computed and then discarded.
Doing nothing keeps a class of bug that reappears with each new prompt or progress affordance, because the next contributor reaches for the nearest boolean and it is the wrong one.
Design
Four properties
Two channels, two properties each, plus one property of the session:
| Property | Question | Source |
|---|---|---|
| stdout escape capability | Can the data consumer render ANSI? | --format, defaulting to |
stdout.is_terminal() | ||
| stderr escape capability | Can the chrome consumer render ANSI? | --format, defaulting to |
stderr.is_terminal() | ||
| stderr cursor addressability | Can chrome be repainted in place? | stderr.is_terminal() |
| Promptability | Is a user present to answer? | /dev/tty, overridable |
auto resolves per channel
RFD 048 resolves --format auto from stdout and applies the result to both channels. That is the conflation this RFD removes, one level up: it makes jp query | less treat the user's terminal as unable to render escapes because stdout is a pipe.
Under this RFD an explicit --format still forces both channels — 048's rule is unchanged for every value a user can type. auto resolves per channel instead: stdout from stdout.is_terminal(), stderr from stderr.is_terminal(). A piped-stdout run on a terminal gets {escapes: false} on stdout and {escapes: true} on stderr, which is what each consumer can actually take.
Forcing, and what cannot be forced
Escape capability and cursor addressability are separate because their override stories differ. A user may legitimately ask for colour a channel cannot render — --color=always | less -R is the canonical case, and --format text-pretty is JP's spelling of it. Nobody legitimately asks to repaint a file: \r\x1b[K written to a redirected stream is not a preference, it is corruption. So --format forces escape capability and has no bearing on addressability.
Cursor addressability is not user-toggleable in either direction. Chrome repaints when the channel supports it and prints plainly when it does not.
Repainting needs both
A repaint is written as escapes and lands on a channel that must be able to act on them, so it requires the conjunction:
can_repaint = caps(Err).escapes && caps(Err).addressableAddressability alone is not enough. The Sink strips escapes whenever escapes is false, so a repaint emitted on that basis is stripped on the way out and the line accumulates instead — the second failure in the Motivation. RFD 091 reached the same conjunction independently for its status line.
Promptability is deliberately independent of both. A piped jp query | less has a user behind it, and JP may ask.
Promptability
Resolved once, at startup, in this order:
--interactivetogether with--no-interactive(or its--non-interactivealias) is a usage error. The two contradict, and silently letting one win hides the contradiction.- An explicit CLI flag decides:
--no-interactive→ not promptable;--interactive→ promptable, or an error if/dev/ttycannot be opened for reading and writing. JP_NONINTERACTIVE→ not promptable.- Otherwise, whether
/dev/ttycan be opened for reading and writing.
The CLI outranks the environment, which is why steps 2 and 3 are separate. A developer whose CI image or wrapper exports JP_NONINTERACTIVE=1 and who adds --interactive to one command is asking to be asked; resolving that to "not promptable" would silently do the opposite of what they typed. This makes promptability a tri-state request — automatic, forced on, forced off — rather than a boolean the environment can |= into.
/dev/tty replaces stdout.is_terminal() as the derivation. It is the controlling terminal, so it survives redirection of stdout and stderr both, and it is absent when the process has none — cron, systemd, a daemonised process, ssh without -t. On Windows the pair is CONOUT$ and CONIN$.
Availability is not the same as being able to use it: a process in a background process group holds a controlling terminal but is stopped by SIGTTIN if it reads from one. See Open Questions.
The two flags are asymmetric in kind, which is intended:
--no-interactiveoverrides the derivation. There is no user; resolve without asking.--interactiveasserts it. There must be a user; fail now if there is not.
The assertion exists because the default is a silent degradation. Without a controlling terminal, JP proceeds unattended and resolves prompts on the user's behalf — which is the right default, and the wrong outcome for a script that meant to be asked. --interactive converts that into a startup error, the way ssh -o BatchMode converts the opposite case.
Neither flag makes JP prompt into a channel that cannot carry a prompt.
Where each value lives
Printer already owns both channels, their sinks, and the ANSI-stripping decision. Channel capability belongs there rather than beside it:
/// What a single output channel can carry.
pub struct ChannelCaps {
/// Whether ANSI escapes reach the consumer intact.
pub escapes: bool,
/// Whether the channel can be repainted in place.
pub addressable: bool,
}
impl Printer {
pub fn caps(&self, target: PrintTarget) -> ChannelCaps;
}erase_line, the waiting indicator, and the retry notice consult can_repaint on PrintTarget::Err. The Sink's strip-or-pass decision becomes caps(target).escapes.
Promptability is resolved in the shell, before any workspace exists, because the workspace ladder (RFD 087) is one of its first consumers. It reaches pre-Ctx code as an argument — TargetEnv already takes it — and Ctx::Term carries it for everything after. There is one resolution and no re-derivation at a call site.
prompt_writer's fallback to out is removed in the same change. The fallback exists to guarantee a prompt renders somewhere, but a prompt in the data channel is worse than no prompt, and once availability is a value the caller has already decided not to prompt.
Ctx::Term keeps what is not a channel property:
pub(crate) struct Term {
pub(crate) args: Globals,
pub(crate) interactive: bool,
pub(crate) width: Option<u16>,
}Term::is_tty is removed rather than narrowed. Every consumer wants one of the four properties by name, and a field called is_tty invites the fifth reader to guess which.
The rename sweep
Sixteen sites across four files name promptability is_tty — parameters, struct fields, and the bindings that read them: cmd/query/tool/coordinator.rs (six, including let can_prompt = is_tty), cmd/plugin/dispatch.rs (five), cmd/label/resolve.rs (four), and cmd/lock.rs (one). They are renamed to interactive in one sweep.
Leaving them is worse than not having split the field at all: a name that merely overloads a word costs a reader one lookup, and a name that contradicts its value costs them a wrong assumption. dispatch.rs's "run jp <name> in a terminal" guidance is corrected in the same pass — being in a terminal stopped being the deciding factor.
The sweep is not uniform, because some values feed both kinds of consumer. The migration is by behaviour, not by identifier:
| Consumer | Source |
|---|---|
| Tool permissions, tool questions, result | resolved promptability |
| delivery, plugin approval, label resolution, | |
| lock timeout, editor confirmation, the | |
| workspace ladder and pickers | |
| Tool progress, waiting indicator, retry line, | can_repaint on stderr |
| status lines | |
| Colour, OSC titles and hyperlinks, box tables | escape capability of the |
| target channel | |
| Table fitting | existing width derivation |
ToolCoordinator::execute_with_prompting is the case that proves the point. It took one flag and fed it both the permission decisions and the tool-progress task, so re-pointing it at promptability silently removed progress from a --no-interactive terminal run. It takes both values. ToolRenderer, lock waiting, and the pre-Ctx workspace prompts are checked individually rather than renamed in bulk.
Testing
Printer::memory is JP's stand-in for a printer whose behaviour a test controls. It gains capabilities as a constructor argument, defaulting to today's behaviour:
// Capabilities derived from the format, as a terminal run would.
let (printer, out, err) = Printer::memory(OutputFormat::Text);
// A run whose stderr is a terminal while its stdout is piped: the case whose
// repaint accumulates today, and the one no single format can express.
let (printer, out, err) = Printer::memory_with(
OutputFormat::Text,
ChannelCaps { escapes: false, addressable: false },
ChannelCaps { escapes: true, addressable: true },
);No Printer trait is introduced. There is one implementation and one test seam; a trait would be an abstraction with no second caller.
Overlap with RFD 091
RFD 091 arrives at much of this section from the other direction. Its enabling predicate is can_repaint under another name, its condition 2 already moves the tty source for chrome from stdout to stderr, and it already proposes capability as a Printer constructor input with an override for the memory constructor.
The two should not both own it. This RFD owns channel capability as a general property; RFD 091's status line becomes one client of can_repaint rather than a feature with its own predicate. 091 keeps what is genuinely its own: the third condition, that a tracing layer writing to stderr disables the line, since that is about a competing writer rather than about what the channel can do.
One place where 091 is deliberately not followed: it states that --format auto continues to resolve by stdout tty-ness per RFD 048, and works around the consequence in its predicate. This RFD changes the resolution instead, which removes the workaround.
Observable behaviour changes
The command from #1068 keeps its current behaviour by default, and gains a way to change it:
jp conversation ls | rg fix
# May prompt: a controlling terminal exists, so a user can answer.
jp --no-interactive conversation ls | rg fix
# Never prompts.That is a deliberate choice, not an oversight. Suppressing prompts because stdout is a pipe is the heuristic this RFD removes; the fix for the report is the override, not a rule about pipes.
The rest are fixes, and each is visible:
jp query | less— stderr repaints work. Today the escapes are stripped and each retry adds a line.jp query 2> log.txt— cursor escapes stop reaching the file.jp queryfrom cron orsshwithout-t— already unattended, but now for the stated reason rather than because stdout happens not to be a terminal.jp query > out.txtfrom a terminal — prompts still appear. Under the old derivation they were skipped, because redirecting stdout was read as "no user".
The last is the largest change in kind: a redirected-stdout run in a terminal becomes interactive where it previously ran unattended. That is the behaviour RFD 048 specifies, and it matches git, sudo, and fzf.
Drawbacks
/dev/tty is not portable in spelling. The Windows equivalents behave similarly but are opened differently and fail differently, so the promptability probe needs per-platform code and per-platform testing.
Opening /dev/tty for reading is a new side effect at startup. It is cheap and it is what the prompt path needs anyway, but it is one more thing that can fail in a sandbox.
Four properties are more to hold than one boolean. The reader who only wants "am I on a terminal" now has to know which terminal and for what. The cost is real, and it is the cost of the questions actually being different.
Per-channel auto deviates from RFD 048. 048 resolves one format from stdout and applies it to both channels, and this RFD keeps that only for explicit values. A reader who knows 048 will find --format auto behaving differently per channel surprising until they reach this document, and 048 needs a note pointing here.
The rename sweep touches sixteen sites in four files, and is not purely mechanical: each has to be classified before it is renamed, and at least one feeds both a prompt decision and a rendering one. It is a wide diff over code that is otherwise stable.
Alternatives
Keep stdout.is_terminal() and add flags for the exceptions
Leave the derivation alone; let --no-interactive and a hypothetical --assume-tty paper over the cases it gets wrong. Rejected: it makes every user who pipes stdout learn a flag to restore the behaviour they had, and it leaves the next affordance to pick the wrong signal again.
Derive promptability from stdin
The workspace ladder already does this, and it is right more often than stdout is. Rejected: it breaks jp query < prompt.txt, where stdin is a file and the user is still at the keyboard. /dev/tty is the only source that survives redirection of any standard stream.
One Terminal type owning all four properties
Fold channel capability and promptability into a single struct passed everywhere. Rejected: it re-creates the coupling this RFD removes, one indirection further out. Channel capability belongs to the thing that owns the channels; promptability belongs to the session.
Introduce a Printer trait for testing
Rejected as a midlayer: one implementation, no second caller, and the test seam Printer::memory already provides is sufficient once it takes capabilities.
Non-Goals
- The detached prompt policy. What JP does instead of prompting when there is no user — auto-approve, use defaults, or fail — is RFD 049. This RFD supplies the signal that policy branches on and takes no position on the branches.
- The
Promptenum. Typed prompt routing is RFD 018. - Whether the workspace conflict prompt should exist. That is RFD 087's design; this RFD only fixes when it fires.
- A toggle for cursor addressability. Repaints happen when the channel supports them. No flag either way.
- The shape of chrome records under
--format json. Whether a consumer can distinguish chrome from data in a merged stream is a machine-output concern, not a capability concern. - A typed prompt transport. Several prompts build on a bare
io::stderr()rather than the printer's prompt channel, so a promptable run can still put a question somewhere the user is not looking. Removingprompt_writer's fallback closes the case where JP has the signal and ignores it; routing every prompt through one transport is RFD 018.
Risks and Open Questions
/dev/tty availability in containers and multiplexers
The probe is correct in the environments it was designed for. Docker without -t, some CI runners, and remote agent harnesses are less predictable, and a false negative there turns an interactive session unattended. --interactive gives the user a way to find out immediately rather than by inspecting the outcome, which bounds the damage but does not remove the risk. Implementation should test these environments explicitly rather than reason about them.
The cost of probing at startup
Every invocation opens /dev/tty, including the ones that never prompt. RFD 048 made the prompt writer lazy for this reason. If the open proves measurable, the probe can be deferred behind a OnceCell, at the cost of promptability no longer being a plain value resolved in the shell.
Background process groups
A process in a background process group holds a controlling terminal, so the /dev/tty probe succeeds, but reading from it raises SIGTTIN and the default action stops the process. jp query "…" & therefore passes --interactive's assertion and then suspends at the first prompt. The shell reports the stopped job, so the damage is visible and contained, but the assertion promises more than it can deliver.
Two ways out: compare tcgetpgrp(tty_fd) with the process group when deriving promptability, which is Unix-only and untested against the multiplexers below; or narrow the contract to "a controlling-terminal endpoint exists" and let the first prompt fail at runtime with a typed error. Unresolved.
Capability and writer destination must change together
RFD 021 proposes swapping a printer's out and err writers at runtime. Capability captured at construction goes stale the moment a writer is replaced by one with different properties. Whichever lands second owes the other an atomic swap of destination and capability.
--interactive on a partially available terminal
/dev/tty can in principle open for writing but not reading. The proposal requires both and errors otherwise, which is the conservative reading; whether any real environment produces that state is unknown.
Implementation Plan
Phase 1: Channel capability on Printer
Introduce ChannelCaps and Printer::caps. Resolve --format auto per channel, keep explicit formats forcing both, and derive addressable from stderr.is_terminal(). Move the Sink strip decision onto caps(target).escapes. Point erase_line, spawn_line_timer, and the retry notice at can_repaint instead of --format and is_tty. Add Printer::memory_with.
Fixes the accumulating-retry-line and log-file cases on its own. Can be merged independently.
Phase 2: Promptability from /dev/tty
Add the read side to open_tty, expose availability as a value rather than absorbing it in prompt_writer's fallback, and resolve promptability from it in the shell. Add --interactive with assert semantics. Remove Term::is_tty.
Depends on Phase 1 for the Printer-side capability that the remaining is_tty readers are re-pointed at.
Phase 3: The rename sweep
Rename the sixteen is_tty sites to interactive, update their doc comments, and correct dispatch.rs's terminal-specific guidance.
Mostly mechanical, but not entirely: each site is classified against the migration table above first, and any that feeds both a prompt decision and a rendering one is split before it is renamed. Depends on Phase 2, and is deliberately separate so the behavioural changes and the mechanical ones bisect apart.
References
- RFD 048: Four-Channel Output Model — defines the channels and specifies the stdout-versus-
/dev/ttysplit this RFD implements. - RFD 049: Non-Interactive Mode and Detached Prompt Policy — the detached prompt policy, which consumes this RFD's signal.
- RFD 087: Session-Scoped Active Workspace — its precedence ladder defers to "the same promptability signal JP already uses elsewhere"; this is that signal.
- RFD 091: Printer-Owned Status Line — its enabling predicate is this RFD's
can_repaint, reached independently. See Overlap with RFD 091. - RFD 021: Printer Live Redirection — swapping writers invalidates capability captured at construction.
- #1068 — the report this RFD starts from.
ssh -o BatchMode=yes— prior art for failing rather than prompting.git,sudo,fzf— prior art for/dev/ttyas the prompt channel.