RFD 120: Ephemeral Reasoning Window
ImplementedDesign2026-10-07Jean Mertz
ExtendsRFD 091
Summary
This RFD adds style.reasoning.window, which streams rendered reasoning into a fixed-height window at the bottom of the terminal while the assistant reasons and erases it when reasoning ends. The window is built on the printer-owned status region (RFD 091) and composes with the existing style.reasoning.display modes.
Motivation
style.reasoning.display offers two kinds of reasoning display, and neither fits a common way of working:
- Persistent (
full,truncate): reasoning is printed into the transcript. A long reasoning block pushes the question, the tool calls, and the start of the answer off screen, and leaves hundreds of lines in scrollback that are read once, while they stream, and never again. - Placeholder (
static,progress,timer,hidden): the user sees that the assistant is reasoning, but not what it is reasoning about. They cannot tell whether it understood the question, and cannot interrupt early when it heads in the wrong direction.
What is missing is the middle ground: watch the reasoning while it happens, then have it go away.
JP already does exactly this for other output. While MCP servers start (style.mcp_startup.stderr_rows) and while tools run (style.tool_call.stderr_rows), the last few lines of the child's stderr scroll by in a bounded window above a status row, and the whole block is erased before the next persistent write. Reasoning is the one stream of transient text that cannot use it.
Doing nothing leaves users choosing between a noisy transcript and no visibility at all.
Design
What the user sees
[style.reasoning]
display = "full"
[style.reasoning.window]
rows = 8While the assistant reasons, the bottom of the terminal shows the most recent rows of rendered reasoning above a status row:
── jp (anthropic/claude-opus-4-8) ─────────────────────────────────────
The loader already handles the empty case, so what is left is whether
`extends` paths are resolved before or after glob expansion. Looking
at `load_config_file_with_extends`, the glob is expanded relative to
the declaring file, which means
⏱ Reasoning… 4.1sThe rows are the same output display = "full" produces: markdown styling, wrapping, code highlighting, and the style.reasoning.background shading. As new rows complete, the oldest scroll out of the window.
When anything persistent is about to render (message text, a tool call, the end of the turn, a stream retry), the window and its status row are erased. No reasoning is left on screen. Resizing the terminal while the window is drawn is the exception (see Drawbacks): narrowing a terminal that reflows its contents can leave parts of the window on screen, and making any terminal shorter than the window pushes the window's top rows into scrollback, where the erase cannot reach them. An interrupt menu erases the window when it opens, like any prompt. Reasoning that resumes after the user picks continue, or after a tool call, opens a fresh, empty window.
The reasoning is still recorded in the conversation stream. jp conversation print --style full replays it as before; the window governs live display only.
Configuration
One new section, style.reasoning.window, with two keys:
[style.reasoning.window]
# Rows of reasoning shown in the window.
# false (default): no window; reasoning renders inline as `display` says.
# true: a tenth of the terminal height.
# N: exactly N rows, still bounded by the terminal height.
rows = 8
# Show a status row below the window with the elapsed time
# ("⏱ Reasoning… 4.1s"). false shows the rows of reasoning alone.
# Defaults to true.
timer = truerows takes the same values as the existing stderr_rows keys and shares its type.
The window is orthogonal to what display renders, not to whether it renders anything:
display | window.rows = false | window.rows set, window renders |
|---|---|---|
full | inline, persistent | all reasoning, in the window |
truncate (<number>) | first N chars inline | first N chars in the window, then the status row |
summary | summary inline | summary in the window |
hidden | nothing | nothing; the window has no effect |
static, progress, timer | placeholder | placeholder; the window has no effect |
The last two rows exist because display mixes two things: which reasoning content to show, and what to show in place of content. The placeholder modes have no content to put in a window.
A combination where the window has no effect is accepted silently, not reported as an error. Config is layered: a user-global window.rows = 8 combined with a per-conversation --cfg style.reasoning.display=hidden is a reasonable thing to write, and refusing it would make one of the two layers unusable. The doc comments on display and window.rows state the rule.
When the window cannot render
A status region renders only on an interactive stderr, with a pretty output format, and with no tracing layer writing to stderr (RFD 091). Outside those conditions the claim yields an inert handle. A region can also render with no room for window rows: when the terminal's height is unknown, or when rows = true resolves to zero on a terminal shorter than ten rows, such as a small split pane (RFD 091).
In either case the renderer behaves as if no window were configured: reasoning renders inline as display says, with the same spacing, shading, and channel as today. The window is a placement preference, not a decision about whether reasoning is shown, so an environment that cannot place it does not hide it.
This includes the common redirect: jp query > answer.md resolves --format auto to text, which disables status regions (RFD 091), so reasoning is written to answer.md exactly as it is without a window. This describes the current shared --format resolution; if RFD 104 lands, whether the window renders and how its rows are styled follow stderr's capabilities instead.
The renderer decides placement once per reasoning block, before it formats the block's first row, because the two paths format differently (see wrap width and background below). It asks the printer how many window rows rows resolves to at the terminal's current size through a new read-only accessor, Printer::window_rows, which returns zero when regions cannot render or when no row fits. A non-zero answer places the block in the window; zero places it inline. The placement holds until the block ends: a resize that removes every window row mid-block leaves the status row alone and does not move the rest of the block inline.
Rendering path
Apart from Printer::window_rows, no printer changes are needed. The chat renderer already produces exactly what a LineSink accepts.
ChatResponse::Reasoning
→ ChatRenderer::render_reasoning
→ markdown buffer + formatter (unchanged: wraps, styles,
holds the in-progress line)
→ emit reasoning output
├─ no window: printer.print(…) (today)
└─ window: split on '\n' → LineSink::push(row)handle_paragraph_chunk re-renders the streaming paragraph and emits only committed visual lines, holding the in-progress line back until it wraps or the paragraph ends. print_block and print_code emit whole rendered blocks. Rows reaching the printer are therefore styled, and prose rows are wrapped, which is the shape the window was built for. Code lines are the exception: Formatter::render_code_line highlights a line but does not wrap it, so a code line wider than the terminal is cut at the window's edge.
The renderer changes:
One emission seam. Reasoning reaches the printer from
print_block,handle_paragraph_chunk,print_code, andemit_pending_separator. Each of them goes through one method that either prints, or splits on\nand pushes the rows into the window. A trailing segment with no\nis held and prefixed to the next emission, so a partial row never enters the window; a segment still held when the window is released is discarded with it. Message content is never routed to the window.Claim, lifetime, and release. The window is a
StatusRegionclaimed on the first reasoning chunk of a block and held in the field that holds the timer region today (reasoning_timer, renamed to cover both uses). A second field records the block's placement, window or inline. Routing reads the placement, neveris_active(), so releasing the window does not turn the rest of its block into inline output.The window stays claimed across consecutive reasoning chunks:
flush_on_transitionreleases it only when the next content is not reasoning. The timer keeps its unconditional release there, which it depends on because the timer path never records a content kind.The other sites that release the timer today release the window too:
flush,flush_for_continuation, the tool-call boundary,skip_tool_call, andreset. Each keeps its order, release then drain the markdown buffer. Reasoning drained after the release belongs to a block placed in the window, so it is discarded rather than printed: it would have been erased with the window a moment later, and the event builder has already recorded it.The claim keeps the timer's 300ms delay: reasoning shorter than that never flashes a window, and longer reasoning appears with its rows already buffered.
Live flow only. The window is claimed only when the renderer's flow is
RenderFlow::Live. Replay renders reasoning asdisplaysays, whateverwindowsays.Wrap width. Windowed rows land on stderr, so prose wraps at
style.markdown.wrap_widthcapped by the chrome channel's width (printer.chrome_columns()), not stdout's (printer.output_width()). With stdout redirected, stdout has no width and the cap would otherwise not apply. Inline reasoning keeps today's stdout-based width.Background. On a terminal,
style.reasoning.backgroundreaches the screen as a\x1b[Kline fill emitted by the formatter (line_fillinjp_term::background), and the region's line filter drops every non-SGR escape,\x1b[Kincluded. Windowed reasoning is formatted with the reasoning background butBackgroundFill::Content, which emits no fill. The formatter keeps the background behind the text and restores it after inline elements that set their own, such as an inline code span that closes with\x1b[49m. The rest of each row is filled by the region, whose background is set throughStatusRegion::background(): the printer worker shades every row it draws, the status row included.Bookkeeping follows
timer. Windowed reasoning is ephemeral chrome. It leaves nothing in the transcript, so it must not count as reasoning that is on screen:reasoning_supplies_separationreturnsfalse.set_response_kind(Reasoning)is not called, soextend_across_tool_calls(RFD 095) does not shade the next tool call. There is no persistent reasoning around it to extend.- No separator is deferred past the window. Blank lines between reasoning blocks become empty rows inside the window; the separator owed at the end of a reasoning block is dropped.
No typewriter delay.
LineSink::pushbypasses the typewriter. The delay paces persistent writes; the window is a preview.
Configuration types
StderrRows (jp_config::style::stderr_rows) is renamed to WindowRows in a preparatory change, since its name and doc comments describe child processes. The existing stderr_rows keys and their serialized values do not change.
A new ReasoningWindowConfig holds rows: WindowRows (default Off) and timer: bool (default true), nested in ReasoningConfig as window, with the usual AssignKeyValue, PartialConfigDelta, FillDefaults, and ToPartial implementations. The renderer maps rows onto the printer's OutputLines with the existing output_lines helper in render/tool.rs.
Drawbacks
- Two keys interact. Some
display×windowcombinations do nothing. Users scanning thedisplayvalues will not discoverwindow, so the doc comments carry the weight. - Reasoning cannot be scrolled back to during the session. Once erased, the only way to read it is
jp conversation print --style full. A futurekeepsetting (see Non-Goals) mitigates this. - The same config renders differently by environment. With a window configured, reasoning is ephemeral in an interactive terminal and persistent under
-v,--log, a non-terminal stderr, or a non-pretty format, because each of those disables status regions. A user who set a window to keep scrollback clean gets the reasoning back in scrollback whenever they debug with-v. - Narrowing the terminal can leave parts of the window behind. Rows are wrapped once, when rendered, and a redraw after the terminal narrows truncates them to the new width (RFD 091's every-row-fits invariant). A terminal that reflows its contents on resize, as Ghostty does, also re-wraps the rows already on screen, shading included. The window then spans more rows than the region walks back to erase, so each redraw while the terminal narrows leaves part of the last frame on screen. The manual check in phase 3 reproduced this, and the tool and MCP startup windows share it. Inline reasoning is hard-wrapped the same way, but nothing erases it, so a reflow only re-wraps it. A terminal shrunk below the window's height can also leave the window's top rows in scrollback, because the erase cannot reach rows the viewport has lost (RFD 091).
- Long code lines are cut. Inline, a code line wider than the terminal soft-wraps; in the window it is truncated at the edge, because a soft-wrapped row would break the region's row count (RFD 091).
Alternatives
An object-valued display. For example [style.reasoning.display.ephemeral] with timer and rows. Rejected: it needs custom deserialization and key-value assignment for an enum that is a string in every other case, and it fixes the window's content to full. truncate and summary could not go into a window.
A new display variant, display = "window". Rejected for the same reason: the content is fixed to full, and the variant duplicates the timer code path and its arms in the separation and turn-view logic.
Split display into a content mode and a placement. This is the cleanest model and removes the "no effect" combinations, but it migrates a public key for no capability window does not already provide. Worth revisiting if a third placement appears.
Line assembly and draw-time wrapping in the printer. A LineSink::write with an open partial line per source, and a wrap mode in the worker. Rejected for this feature: the formatter already assembles and wraps reasoning rows before they reach the printer. Both would help child stderr (long log lines, \r progress output) and can be proposed on those merits.
Non-Goals
- Keeping reasoning after it ends. A
keep = Nsetting that prints the last N window rows persistently when the window closes is a natural follow-up. It needs the renderer to hold its own copy of the rows, since the region's buffer is private, and it turns windowed reasoning back into persistent output, which reopens the separation and tool-call shading decisions in Rendering path. - Windowing message text or tool output. Only reasoning is routed to the window.
- Changing the
displaymodes. Their behavior without a window is unchanged. - Implementing
summary. It is still unimplemented; once it lands, its output goes into the window like any other content. - Interacting with the window. No scrolling, pausing, or expanding. The status region has no input handling (RFD 091).
Risks and Open Questions
- Default size.
rows = truesizes the window at a tenth of the terminal height, chosen in RFD 091 for tool stderr: five rows on a 50-row terminal. That may be too little prose to follow. If so, the fix is a different divisor for this client, which needs a newOutputLinesvariant injp_printer. - Flicker. Each completed row repaints the whole window. Paragraph prose completes a row once per
wrap_widthcolumns (80 by default); code blocks and lists can complete rows faster. A burst of pushes raises one refresh per worker wakeup (LineSink::push). Whether that repaint rate flickers depends on the terminal and the token rate. Checked on a real terminal in phase 3 (Ghostty, with Anthropic's reasoning stream), the window does not flicker. - The window after an interrupt. The flush that runs before the interrupt menu opens releases the window, so reasoning after continue starts in an empty window and the rows the user was reading when they pressed Ctrl-C are gone. Checked on a real terminal in phase 3, this does not read as lost context, so the window is not kept across the menu. Keeping it would mean leaving it claimed through that flush and through the reset that follows continue, the way
reset_preserving_regionalready keeps the content region open across a stream retry. Reply starts a fresh window either way: it begins a new assistant section after a user message, and a reasoning region never survives a user message (RFD 095).
Implementation Plan
Rename
StderrRowstoWindowRows. No behavior or config change. Mergeable on its own.Config. Add
ReasoningWindowConfigunderstyle.reasoning.window, default off, with user-facing doc comments, and point thedisplaydoc comment at it. No renderer effect yet. Mergeable on its own.Renderer.
Printer::window_rows, the emission seam, the per-block placement, the window's lifetime and release, the region background, chrome-width wrapping,timerbookkeeping parity, and the live-flow gate. Tests inrender/chat_tests.rsagainst an interactive memory printer cover: no reasoning bytes on stdout, rows pushed in order, the window surviving consecutive reasoning chunks, release at the tool-call boundary and at message start, drained reasoning not reaching stdout at either boundary, no shading on the following tool call, the background restored after an inline code span, replay ignoring the window, and inline output when the region is inert or would have no window rows. A screen-level test renders throughjp_pty's terminal model (the harnessjp_printer's region tests use) and asserts that the window is on screen while reasoning streams and gone once the message starts. Finished with a manual check on a real terminal for flicker, the default size, a resize mid-reasoning, and the window after an interrupt; the last one decides whether continue keeps the region. Depends on 2.
References
- RFD 091: the status region, its rolling window, and its rendering conditions.
- RFD 095: reasoning-region shading across tool calls, which windowed reasoning must not trigger.
- RFD 104: per-channel terminal capability, which would change when the window renders under a redirected stdout.
crates/jp_cli/src/cmd/query/mcp_startup.rs: the existing client that feeds a window from a stream of lines.crates/jp_cli/src/render/chat.rs:render_reasoning, theTimerdisplay path, andhandle_paragraph_chunk.