RFD 091: Printer-Owned Status Line
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-03
- Extends: RFD 048
Summary
This RFD makes the ephemeral status line — ⏱ Waiting… 9.2s (receiving response data) — a first-class concept owned by jp_printer. At most one status line is visible at a time; the printer's worker thread draws it, ticks its elapsed time, and clears it automatically before any printer-managed write on any channel. Seven bespoke timer and temp-line mechanisms across jp_cli become clients of this one primitive.
Motivation
JP renders ephemeral chrome — a single self-overwriting line on stderr — in seven places, each with its own hand-rolled draw, tick, and clear logic:
| Site | Mechanism |
|---|---|
Waiting indicator (cmd/query/turn_loop.rs) | LineTimer |
Reasoning timer (render/chat.rs) | LineTimer |
Lock-wait countdown (cmd/lock.rs) | LineTimer |
Background-task drain timers (lib.rs, two sites) | LineTimer |
Tool "preparing" temp line (render/tool.rs) | spawn_tick_sender + manual \r…\x1b[K rewrites |
Tool execution progress (cmd/query/tool/coordinator.rs) | spawn_tick_sender |
All of them fight over the same invariant: an ephemeral line must be erased before any persistent write, and must not disappear before the persistent write arrives. Today that invariant is enforced by per-site discipline, and the codebase carries the scars of getting it wrong: clear_temp_line() before the interrupt menu, cancel_reasoning_timer() inside flush_on_transition, the "don't pre-clear line_active" warning in ToolRenderer::reset, and the waiting indicator's finish().await ordering dance in the turn loop. Each was a bug fixed at one site; none of the fixes protects the next site.
The most user-visible instance was the waiting-indicator gap: the indicator was torn down by the first provider event of any kind — including an SSE keep-alive ping that renders nothing — leaving the user staring at a blank terminal for many seconds while the model produced no visible output. A user who sees a progress indicator vanish and then nothing happen reasonably concludes the program crashed. That instance is fixed: LineTimer (in jp_cli::timer) carries a status channel, and the turn loop finishes the indicator only on events that render. But the fix is one client-side patch on the missing abstraction; the bug class remains open at every other site, and every future chrome feature reopens it.
Doing nothing means each new indicator re-implements draw/tick/clear, and each new combination of chrome and content is a fresh opportunity for a stale line, a clobbered row, or a premature disappearance.
Design
Concept
A status line is a single ephemeral chrome line: a subject, an elapsed time, and an optional replaceable detail.
⏱ Waiting… 9.2s (receiving response data)It is chrome in the RFD 048 sense — written to stderr, never part of the persistent transcript — with one added contract: the printer guarantees it is cleared before any printer-managed write, on any channel, reaches the terminal. One stderr writer lives outside the printer: the optional tracing layer (-v, --log, --log-file=-), which RFD 048 deliberately keeps out of the printer. The worker cannot clear before writes it never sees, so the enabling predicate below disables status lines while that layer is active.
API
Callers acquire a status line from the printer and hold an RAII handle:
// Claim a status line. Returns a no-op handle when status lines are
// disabled (see "Enabling predicate" below).
let status = printer.status_line(StatusStyle {
delay: Duration::from_secs(2),
interval: Duration::from_millis(100),
format: Box::new(|secs, detail| match detail {
Some(d) => format!("⏱ Waiting… {secs:.1}s ({d})"),
None => format!("⏱ Waiting… {secs:.1}s"),
}),
});
status.set_detail("sending request");
// ... later ...
status.set_detail("receiving response data");
// Release: the printer clears the line. Dropping the handle does the same.
drop(status);General callers holding a full Printer — the turn loop, lock acquisition, the shutdown drain — acquire from Printer as above; chrome-only renderers acquire through ErrChannel::status_line (below), keeping the stderr-only boundary.
The handle is Send and requires no async runtime: there is no finish().await, because the caller no longer owns the clear-before-write ordering — the printer does. A client that renders content simply writes through the printer as it always has; the worker clears the status line first.
The owning handle is not Clone; it releases the line on drop. Components that only push detail (e.g. the turn loop's status transitions) receive a cloneable StatusDetail updater split off the owner, so shared ownership never blurs who releases the line.
The format closure (rather than a fixed template) is required by existing clients: the lock-wait and drain timers render a countdown, not an elapsed time.
Chrome renderers hold an ErrChannel, not a Printer — the stderr-only view exists precisely so tool chrome cannot reach stdout. Status lines are chrome, so acquisition is part of the chrome-facing surface:
impl ErrChannel {
pub fn status_line(&self, style: StatusStyle) -> StatusLineHandle;
}ToolRenderer migrates through this method and keeps its ErrChannel; it does not regain full-printer access.
Release contract
- Releasing (dropping the owner) enqueues a release command. Commands enqueued from one thread stay ordered: a release followed by a print from the same thread is processed in that order.
- Every
Printwith non-empty content clears a drawn status line before writing, whether or not a pending release has been processed. A stale entry can never sit above content. "Non-empty" is byte-level, not glyph-level: newline-only and control-sequence-only writes clear too. Empty-content tasks are no-ops (no clear, no redraw), and the status line's own draw and clear writes are exempt — the worker does not recurse. - A released entry is never redrawn once its release command is processed.
- Across threads, drop is eventual cleanup only: a released entry may be redrawn once more if another thread's print is processed before the release command drains. The stale window is bounded by the queue, and the second rule keeps the stale line below content.
The design deliberately provides no blocking release. Same-thread ordering covers the release-then-render pattern used by every current client, and a blocking release would reintroduce the async ordering surface (finish().await) this design removes.
Enabling predicate
A status line renders iff:
- the resolved output format permits terminal control (
text-pretty), - the chrome channel (stderr) is an interactive terminal, and
- no tracing layer writes to stderr (
-v,--log, or--log-file=-, absent--quiet).
| Situation | Status line |
|---|---|
--format json / json-pretty | off (chrome is NDJSON) |
--format text | off (the non-pretty Sink strips \r and escape sequences; the line would smear as repeated text) |
text-pretty, stderr is a terminal | on |
| stderr is not a terminal | off, regardless of format |
-v / --log / --log-file=- on a terminal | off (stderr carries live logs, a persistent stream outside the printer) |
Condition 2 changes the tty source for this chrome from stdout to stderr: today chrome gating keys off stdout (ctx.term.is_tty), so jp query 2>file with stdout on a terminal writes \r\x1b[K bytes into the file — under this predicate it does not. --format auto continues to resolve by stdout tty-ness per RFD 048; this RFD does not change format resolution.
Condition 3 mirrors the guarantee's scope: tracing writes to stderr directly, behind the worker's back, and a user opting into live logs has chosen stderr as a persistent stream where an ephemeral line cannot survive. The shell knows this at logging setup, before the printer is constructed, so it feeds the same constructor input as condition 2.
To make condition 2 explicit and testable, terminal capability is a constructor input: Printer::terminal captures stderr's tty-ness at construction, and the memory constructor accepts an explicit capability override so tests can exercise draw, clear, and redraw without a real terminal. The exact constructor shape is an implementation detail.
Worker integration
The printer already serializes every write — stdout, stderr, and /dev/tty — through one background worker thread (Worker::run in jp_printer::printer). That choke point is the entire architectural argument for this design: it is the only place in the process where "clear chrome, then write content" can be made atomic with respect to all three channels.
Three changes to the worker:
- State. The worker holds a stack of active status-line entries (claim
Instant, format closure, current detail). The top entry is the one rendered. Claim, detail-update, and release arrive as newCommandvariants. - Ticking. The worker's
rx.recv()becomesrx.recv_timeout(interval)while a status line is active; on timeout it redraws the top entry with updated elapsed time. With no active entry, it blocks as today. This removes the tokio timer tasks entirely — timing moves to the thread that owns the terminal. - Clear-before-write, redraw-after. Before processing any
Printtask with non-empty content, the worker writes\r\x1b[Kto stderr if a status line is drawn; after the task completes, it redraws the line. Coexistence with streaming content is therefore at task granularity: the line redraws between print tasks, and a long typewriter task — one blocking loop inside the worker — hides it until the task completes. For instant prints (tool chrome, block-at-a-time streaming) this approximates the cargo/indicatif model. Clients that want disappear-on-content behavior (the waiting indicator) simply release the handle at that moment, as they already do.
Concurrent claimants
Claims form a stack (LIFO): the most recent claim is rendered; releasing it re-exposes the one below; releasing a non-top entry removes it from the middle. A stack matches the actual nesting in the code — a tool "preparing" line claimed during a streaming cycle sits on top of nothing today, but the moment two indicators overlap (e.g. reasoning timer active when a tool call starts streaming), LIFO produces the intuitive result without either site knowing about the other.
Interactive sessions
JP's prompts do not bypass the printer: prompt output flows through Printer::prompt_writer() / owned_prompt_writer() as PrintTarget::Tty tasks, serialized by the same worker. The clear-before-write rule covers prompts exactly as it covers stdout and stderr.
Clear-before-write is not sufficient for them, though. A prompt session is a sequence of small Tty writes with the widget owning the cursor in between; a status-line redraw landing between those writes corrupts the widget. Suspension is therefore tied to the prompt-writer boundary, not to call sites: acquiring a prompt writer (prompt_writer() or owned_prompt_writer()) suspends status rendering — the line is cleared and redraws are blocked — for the writer's lifetime. Prompt code carries no guard obligation; the prompt sites spread across jp_cli (ToolPrompter, the interrupt handler, cmd/init.rs, cmd/target.rs, the conversation subcommands) need no changes.
Two consequences for the implementation:
PrinterWriteris currentlyCopy; a suspension-carrying writer needs a guard type. The concrete shape is an implementation detail.- The lock-contention prompt renders via
err_writer()today, outside the boundary; it migrates toprompt_writer()alongside the lock-wait countdown (phase 3).
One explicit guard remains, for the single writer genuinely outside the printer: the external $EDITOR, which takes over the terminal as a child process and touches no prompt writer.
let _pause = printer.suspend_status(); // clears the line, blocks redraws
// ... run $EDITOR ...
// guard drop: redraw resumesMigration
All seven sites become clients. jp_cli::timer (LineTimer, spawn_line_timer, spawn_tick_sender) is deleted once the last client migrates. The tool "preparing" temp line keeps its separator bookkeeping and its temp-to-permanent header conversion in ToolRenderer; only the draw, tick, and clear mechanics move to the printer.
Drawbacks
- The printer becomes stateful across writes. Today each
Printtask is independent; with this change the worker carries cross-task state (the claim stack, drawn/not-drawn) that every write path implicitly interacts with. Complexity is conserved (Tesler): it moves out of seven call sites into one primitive — but bugs in that primitive now affect all chrome at once. jp_printeris foundational. Every crate that prints depends on it; its API surface grows, and per Hyrum's Law the rendered chrome format becomes something users' scripts may match on.- The worker gains a timing loop.
recv_timeoutpolling is cheap but makes the worker's behavior time-dependent, which is harder to test than the current pure command-processing loop.
Alternatives
- Keep the status quo (
LineTimer+ per-site discipline). The waiting-indicator fix shipped this way and works. Rejected as the end state: it fixes one site per bug, the clear-ordering guarantee needs an async context (finish().await) that synchronous renderers don't have (seecancel_reasoning_timer's workaround), and the tool temp-line machinery remains bespoke. - A status-line actor outside the printer. A separate task owning the line, with renderers notifying it before writes. Rejected: it recreates the ordering problem it is meant to solve — notifications race with writes unless every write goes through the actor, at which point it is the printer.
- Last-writer-wins instead of a claim stack. Simpler, but a released claimant would leave the screen blank even when an earlier claimant is still logically active.
Non-Goals
- Multi-line status regions (parallel progress bars, spinner groups). The concept is deliberately one line; nothing in the design precludes extending the region later.
- Changing what any indicator says or when clients claim/release. Reasoning display modes, waiting-indicator status wording, and lock-wait countdown semantics are unchanged; only the mechanics move.
- Status lines during interactive sessions. A prompt session suspends the status line rather than coexisting with it; rendering chrome alongside an active prompt widget is out of scope.
Risks and Open Questions
- Typewriter granularity. Under the task-boundary coexistence contract, a long typewriter print hides the status line (and freezes its elapsed display) for the task's duration. Accepted as a limitation: status lines rarely coexist with typewriter output. The known refinement, if it matters in practice, is yielding redraws between typewriter batches inside the worker loop.
- Flicker. Clear-redraw around every write during heavy streaming could flicker on slow terminals. Mitigation if observed: skip the redraw when another write is already queued, coalescing to one redraw per batch.
- Windows console.
\r\x1b[Khandling and the worker'srecv_timeoutresolution (~15.6ms scheduler tick) need verification on Windows, same as the existing typewriter batching did.
Implementation Plan
Each phase is independently reviewable and mergeable.
- Printer primitive. Add the claim stack,
Commandvariants,recv_timeoutticking, clear-before-write/redraw-after, the enabling predicate, prompt-writer suspension, and the explicitsuspend_statusguard tojp_printer. Unit tests againstPrinter::memorywith an explicit terminal-capability override. - Waiting indicator. Migrate the turn loop's indicator (including its status transitions) from
LineTimerto the printer handle. Theturn_loop_testswaiting-indicator suite carries over as the characterization tests. - Simple timers. Migrate the reasoning timer, lock-wait countdown, and both drain timers; move the lock-contention prompt from
err_writer()toprompt_writer(). Deletespawn_line_timerandLineTimer. - Tool temp line. Migrate
ToolRenderer's preparing line and the execution-progress ticker; deletespawn_tick_senderand the manual rewrite/clear paths (clear_temp_line,rewrite_temp_line, theline_activebookkeeping). This is the largest phase and depends on phases 1–3 only for the primitive's API having settled.
References
- RFD 048 — the four-channel output model; defines "chrome" and the printer's ownership of stdout/stderr/tty.
- RFD 088 — the unified editor service and inline reply widget; its cursor-owning prompt sessions are what prompt-writer suspension protects, and its open widget/printer coordination risk overlaps with the problem addressed here.
crates/jp_cli/src/timer.rs— theLineTimerinterim solution this RFD replaces.crates/jp_printer/src/printer.rs— the worker loop this RFD extends.