RFD 093: Inline-First Query Composition
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-02
- Extends: RFD 088
Summary
A bare jp query composes the query in the inline editor (the reedline-based widget from RFD 088) instead of opening the external editor. Ctrl+X escapes into the full external-editor query document, and a leftover query draft pre-seeds whichever surface opens. A new query.compose_in_editor setting selects the surface using the existing ComposeInEditor spectrum, and the widget is renamed from InlineReply to InlineEditor to match the user-facing vocabulary.
Motivation
jp query has five entry paths today, and none of them use the inline editor:
| Invocation | Behavior |
|---|---|
jp q (bare, tty) | Opens the external editor with the full query document. |
Errors with MissingEditor if no editor resolves. | |
jp q <query> | Sends directly. |
echo x | jp q | Sends directly. |
jp q [<query>] -e | External editor, seeded with <query>. |
jp q -E | No editing; replays the last request or sends continue. |
This has three problems:
- A bare
jp qhard-requires a configured external editor. The inline editor works on any tty with zero configuration; requiring$EDITORfor the flagship command's zero-argument form is an unnecessary setup hurdle. - Short queries pay for a full editor round-trip. Spawning an editor, writing one line, and saving is heavy for the most common interaction.
- The compose vocabulary stops short of the query. RFD 088 gave interrupt replies and tool responses the
compose_in_editorspectrum (inline-first, editor-first, editor-only, inline-only); the initial query, the most-composed message of all, has no equivalent.
Doing nothing leaves the initial query as the last high-frequency composition path that hard-requires the external editor.
Design
Behavior
| Invocation | New behavior |
|---|---|
jp q (tty) | Inline editor. Ctrl+X escapes to the full query |
| document in the external editor, seeded with the buffer. | |
| A cancelled or empty external editor returns to the | |
inline editor with the buffer intact. Ctrl+C aborts. | |
jp q + compose_in_editor = true | External editor first (today's behavior); a spawn |
| failure falls back to the inline editor. | |
... = "always" | External editor or error; never the inline editor. |
... = "never" | Inline editor only; Ctrl+X is unbound. |
jp q [<query>] -e | Forces composition and prefers the external editor, |
seeded with <query> when given. A configured "always" | |
| keeps its no-inline-fallback semantics. | |
jp q (tty), empty submit | An empty (or whitespace-only) Enter in the inline editor |
| aborts composition with the existing "Query is empty, | |
| ignoring." notice; the query draft is not consumed. | |
jp q --quote | Forces composition (as today); the surface follows |
query.compose_in_editor. | |
jp q <query>, piped stdin, -E | Unchanged: send directly / skip composition. |
| No tty | Unchanged: query.compose_in_editor has no effect |
without /dev/tty (external editor or MissingEditor). |
Two consequences worth naming: a bare jp q no longer requires any editor configuration, and quick one-line queries become cheaper than an editor round-trip while Ctrl+X keeps the full document one keystroke away.
The empty-submit rule is deliberately asymmetric with the escape path: an empty external editor result returns to the inline editor because it may be discarding a buffer the user already typed, while an empty inline Enter has nothing to preserve and ends the invocation, matching today's empty-query behavior.
Configuration
[query]
compose_in_editor = false # false | true | "always" | "never"A new top-level [query] section holds query-composition behavior, mirroring how [interrupt] holds query-interrupt behavior. The value type is the existing ComposeInEditor enum, which moves from jp_config::interrupt to jp_config::editor — it is editor vocabulary, not interrupt vocabulary, and it now has consumers in two sections. The move changes no serialized format (the enum is a value type, not a key).
The enum's fallback semantics are per-context. For interrupts the fallback target is the interrupt menu; for the query there is no menu, so:
false(default): inline editor first;Ctrl+Xescapes to the external editor.true: external editor first; if it cannot open, fall back to the inline editor."always": external editor only; if it cannot open, the query aborts with an error."never": inline editor only; theCtrl+Xescape is disabled.
The enum's doc comment becomes context-neutral about the fallback target; each context's field documents its own.
When /dev/tty is unavailable, query.compose_in_editor has no effect: composition follows the existing external-editor behavior, including MissingEditor when no editor resolves. This matches the shipped interrupt.*.compose_in_editor keys, which document themselves as inert in non-interactive (no-tty) mode.
-e/--edit, -E/--no-edit, and --quote stay outside AppConfig, as they are today: apply_cli_config ignores them, so they are never recorded as a conversation config delta the way --cfg query.compose_in_editor=... is. Instead, compose_query resolves the effective policy after config resolution and delta recording. The flags control whether composition happens (-e and --quote force it; -E skips it), and -e additionally prefers the external editor for this invocation: a configured false or "never" behaves as true, while true and "always" are unchanged — -e never re-enables the inline fallback that "always" opts out of.
Query draft lifecycle
A leftover QUERY_MESSAGE.md draft takes part in composition on both surfaces; item 1 defines what seeds the query text when the draft and the invocation both provide some. Both surfaces run the same query-document config step: parse the QueryDocument structure, parse the TOML preamble, resolve model aliases, compute the notice delta against the conversation's current config, and return the parsed partial for recording. The surfaces differ only in where the query text comes from; what gets recorded is identical on both — today the parsed partial, wholesale, and uniformly whatever RFD 080 changes that to when it lands.
- Query text is seeded by precedence: the invocation-built request (
--quote, piped stdin,<query>, replay seed — as built by the query command) when non-empty; otherwise the draft'sdoc.query; otherwise the buffer starts empty. The draft's config preamble and history metadata are preserved regardless of which source wins; they stay in the file and never enter the widget. When invocation text displaces a non-emptydoc.query, a chrome notice says so; a cancelled composition reverts the draft file, so the displaced text is lost only on a successful send. A direct send (jp q <query>or piped stdin without-e/--quote) bypasses composition entirely: the draft is neither seeded, nor modified, nor consumed. - On a non-empty inline submit, the draft's config preamble is applied exactly as the external-editor path applies it: the parsed partial is recorded on the conversation as a config delta. An empty (or whitespace-only) submit follows the empty-query rule instead: no config delta is recorded and the draft is kept. The preamble's effect — its delta against the conversation's current config — gates only a chrome notice; it does not change what is recorded. When the effect is non-empty, the notice
Applying config from the query draft.tells the user that config came along with the text, suffixed with(Ctrl+X to review)only when the editor escape is wired and an external editor resolves. A preamble that is textually different but semantically a no-op stays silent — what matters is whether the draft changes the conversation config, not how the file is worded. - A draft that fails to parse escalates to the external editor instead of composing inline. A TOML preamble error re-opens the full document with the error annotated (the existing re-open mechanism), the inline buffer winning for the query text. A structural error (
QUERY_MESSAGE.mdcannot be parsed as aQueryDocumentat all) is detected when the draft is first read: the editor opens on the file exactly as it is, with the error reported as a chrome notice. This replaces today's silent fallback, where a structurally damaged draft is swallowed byunwrap_or_default()and the force-write rebuilds the file from an empty document, destroying the draft's query text. When the editor cannot be opened —compose_in_editor = "never", no editor configured, or a spawn failure — composition aborts, the parse error is printed, and the draft is kept. The abort message names the recovery options: configureeditor.cmdand runjp q -e, or edit or deleteQUERY_MESSAGE.mddirectly. - On
Ctrl+X, the buffer overwritesdoc.queryin the document handed to the external editor. Todayedit_queryuses the passed query only whendoc.queryis empty; this flips to buffer-wins: the buffer is the user's most recent statement of the query text, whatever seeded it. The config preamble persists untouched and keeps today's behavior: parsed and recorded as a conversation config delta, applied from the next turn until RFD 080 moves it into same-turn config resolution. - The draft is consumed (removed) after a successful turn regardless of which surface composed the message. Today only the external-editor path triggers removal; without extending it, the next bare
jp qwould re-seed stale text after an inline submission. Removal loses no config: the preamble was applied on submit (item 2), on escape it round-tripped through the editor (item 4). Displaced query text is covered by the notice rule in item 1.
Composition loop
A compose_query function in jp_cli (next to the existing edit_query) implements the policy loop: inline editor via jp_inquire, external editor via edit_query, failure reporting via report_editor_failure.
The loop is deliberately not shared with InterruptHandler::collect_reply. The two differ in fallback target (menu vs. abort) and return type (String vs. (String, PartialAppConfig)); after parameterizing over both, the shared core is a ~15-line match — the wrong abstraction. The shared primitives (InlineEditor, ComposeInEditor, build_editor_backend, report_editor_failure) are already shared. If a third full compose loop appears, extract then.
Alongside the composed text and config, compose_query reports two independent facts: whether the request still needs echoing (only editor-composed text was never rendered on the terminal; an inline submission remains visible in scrollback) and whether a draft file needs consuming after a successful turn. The existing query_from_editor boolean conflates the two and cannot express an inline submission seeded from a draft; it is replaced.
Rename: InlineReply becomes InlineEditor
The widget's user-facing name is already "inline editor": the config section is editor.inline.*, the config struct is InlineEditorConfig, and doc comments contrast it with "the external editor". InlineReply names the widget after its first use (interrupt replies) and is already wrong for tool-result editing, let alone query composition.
| Current | New |
|---|---|
InlineReply | InlineEditor |
ReplyOutcome | InlineOutcome (variants unchanged) |
ReplyEditMode | InlineEditMode (name-matching |
jp_config::editor::InlineEditMode; the | |
two types stay separate — jp_inquire | |
does not depend on jp_config) | |
PromptBackend::inline_reply | inline_edit |
| prose: "inline reply widget" | "inline editor" |
"Compose" remains the name of the policy axis (where a message is composed); "inline editor" and "external editor" are the two surfaces. An Inline Editor entry is added to the ubiquitous-language glossary.
Drawbacks
- The flagship command's default changes. Users who rely on bare
jp qopening$EDITORmust set one config key or pass-e. Pre-release, this is acceptable; post-release it would not be. - ~40 lines of duplicated compose policy between
compose_queryandcollect_reply, accepted deliberately (see Composition loop). - A new top-level config section with a single key. Justified by the
[interrupt]precedent and by giving future query-scoped settings a home, but it is surface area. - The inline editor is a line editor. Long drafts and
--quoteseeds are less comfortable there than in$EDITOR;Ctrl+Xis the mitigation.
Alternatives
editor.compose_in_editorinstead of a[query]section. Rejected: the name doesn't say "query", and it would sit next toeditor.inline.*, which applies to all composition contexts — a confusion trap.- A global compose default with per-context overrides (
editor.compose_in_editorinherited by query and interrupt contexts). The most orthogonal shape, but it changes the semantics of two shipped interrupt keys and makes their resolved configsOption-typed for a need nobody has expressed. Flip condition: a fourthcompose_in_editorconsumer — the likely candidate is the inquiry free-text path, which today hardcodes its editor escape. - Extracting a shared compose state machine. Rejected as a midlayer: the contexts' failure semantics are the part that differs.
- Naming the widget
InlineComposer. Rejected: it moves the code away from the user-facingeditor.inline.*keys, and "compose" is better kept for the policy axis. - Keeping the external editor as the default and adding inline as opt-in. Rejected: the inline editor is the better zero-config default, and the escape hatch preserves the full-document flow at one keystroke.
Non-Goals
- TOML editing in the widget. The inline editor never renders or edits the config preamble; editing config requires the
Ctrl+Xescape. The draft file's preamble is still applied on inline submit (see the draft lifecycle) — out of scope is only growing TOML display or editing into the widget itself. - File-path query arguments.
jp q ./text.txtcontinues to send the literal string; usejp q "$(< text.txt)"or stdin. - Compose policy for inquiry free-text answers. The tool prompter keeps its hardcoded editor escape; giving it a
compose_in_editorkey is future work. - Non-interactive and detached behavior. No-tty flows are unchanged (see RFD 049).
- Per-context external-editor selection. Still one configured external editor, as in RFD 088's non-goals.
Risks and Open Questions
- Large seeded buffers in reedline. Multi-line drafts and
--quoteblockquotes seed the widget with substantial text; rendering and cursor behavior at that size need a spike. - Echo after inline submission.
should_echo_requestre-echoes the request when the external editor took over the screen. A submitted inline buffer stays visible in scrollback, so no re-echo should be needed — validate that the rendered transcript reads correctly. - Tty detection. The widget requires
/dev/tty; detection must be consistent with howPrinter::prompt_writeracquires it, so the fallback decision and the render target cannot disagree. - Draft seeding edge cases. Whitespace-only drafts and drafts containing only a config preamble need defined behavior (proposed: seed an empty widget; the preamble applies only on a non-empty submit, per the lifecycle rules). Unparseable preambles and structurally damaged documents are covered by the escalation rules in the draft lifecycle.
Implementation Plan
Phase 1: rename (independent, behavior-preserving)
InlineReply → InlineEditor and the associated renames across jp_inquire, jp_cli, docs, and doc comments. Add the glossary entry. Mergeable on its own.
Phase 2: configuration
Move ComposeInEditor to jp_config::editor; make its doc comment context-neutral. Add QueryConfig with compose_in_editor and the full partial plumbing (AssignKeyValue, PartialConfigDelta, FillDefaults, ToPartial). Leave -e, -E, and --quote out of apply_cli_config (as today); their invocation-local effects are resolved in compose_query after config resolution and conversation delta recording. Depends on nothing; mergeable with the key unused.
Phase 3: compose loop and draft lifecycle
Add compose_query; wire bare jp q (and forced composition) through it. Flip edit_query seeding to buffer-wins; apply the draft preamble on inline submit (with the delta-gated notice and the parse-error escalation); extend draft consumption to inline submissions. Replace the query_from_editor boolean with the two independent facts from the Composition loop section. Update the --edit, --no-edit, and --quote help text to the new semantics (compose interactively; the surface follows query.compose_in_editor), so --help and behavior change together. Depends on phases 1–2.
Phase 4: documentation
Update docs/configuration.md and the usage docs for the new default, the [query] section, and the flag semantics.
References
- RFD 088 — the
EditorBackendtrait, the inline editor widget, and thecompose_in_editorspectrum this RFD extends to the query. - RFD 080 — moves editor-provided config into same-turn config resolution. This RFD does not require it: until it lands, the draft preamble keeps today's behavior (parsed and recorded as a conversation config delta, applied from the next turn). RFD 080 changes when draft config applies — on both surfaces uniformly — not whether the preamble survives composition.
- RFD 049 — non-interactive mode; untouched by this RFD.