RFD D47: Divergent Display View for Tool Results
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-06-03
Summary
This RFD lets a local tool author a separate display view of its result — the bytes shown to the user — distinct from the assistant view that is recorded into the conversation and replayed to the model. The display view is produced once at turn time by a new tool Action, stored in tool-call event metadata, and replayed from storage. It is opt-in per tool and defaults to being identical to the assistant view, so existing behavior is unchanged.
Motivation
A tool returns a single result today. The user sees a projection of the same bytes the assistant sees: JP can truncate it (inline_results), hide it (hidden), or style it, but the human and the model always look at the same underlying content. There is no way for a tool to show the user different content than it feeds the assistant.
Two cases want exactly that:
fs_read_fileline gutter. Numbering each line helps the assistant feed a range straight back into the line-addressed tools (fs_read_file,git_blame,git_diff_file). But the terminal renderer keys syntax highlighting off a leading```langfence, and a1:gutter defeats that detection, degrading the human's view. The tool wants numbered output for the assistant and clean, fenced output for the terminal.- Summary vs. full dump. A tool may want to show the human a terse one-line summary while handing the assistant the full payload, or the reverse.
Doing nothing forces every such tool to pick one audience and degrade the other.
Design
Vocabulary
A tool result has two views:
- Assistant view — recorded into the conversation stream, replayed into the model's context.
- Display view — rendered to the user.
The default is display view == assistant view. Divergence is opt-in.
This is a different axis from the four output channels in RFD 048, which separate JP's own streams (stdout / stderr /
/dev/tty/ log). This RFD is about a single tool result having two audiences.
The third action
jp_tool::Action today has Run and FormatArguments. This RFD adds a third variant (working name RewriteResult). After a tool runs, JP invokes it again at turn time with the assistant view as input; the tool returns the display view. This mirrors the existing custom-argument-formatter path, which already invokes a local tool with Action::FormatArguments and awaits it on the render path (render::tool::format_args_custom).
Run ──▶ assistant view ──▶ recorded into the conversation (replayed to the model)
│
RewriteResult ◀────────┘ (turn time, once, opt-in)
──▶ display view ──▶ stored in event metadata ──▶ rendered to the user
└─▶ replayed from storage at print timeCompute once, store, replay from storage
The display view is computed once at turn time and stored in the tool-call event metadata, exactly as the custom argument formatter already stores its output (rendered_arguments, drained by the turn loop into event metadata). jp conversation print reads the stored view and renders it without re-invoking the tool — the same reason the replay path is synchronous today.
This is the established pattern: JP already persists large rendered output (the fs_modify_file formatter stores a full diff, often hundreds of lines) and replays it from disk. Storing a divergent result rendering is the same thing, not a new burden.
Store only on divergence. A display view is persisted only when it differs from the assistant view. The default case (identical) stores nothing extra; only opted-in, genuinely divergent tools pay the storage cost.
Configuration
Divergence is opt-in via a field on the conversation::tool config, with a * default, defaulting to off. It lives there because it concerns representation, not capability — access is for fs/env/net scope and is the wrong home.
The opt-in flag does double duty: it is also the declaration that a tool implements RewriteResult. JP only sends the new action to tools that opted in, so an older or third-party tool never receives an action it does not recognize. This reuses the existing "branch on the action before any I/O" contract that FormatArguments already relies on.
Fallback and the vigilant user
When no display view is stored — the tool did not opt in, errored, was cancelled, or is absent at replay — JP renders the assistant view. This is also the result = "ask" escape hatch: a user who wants to verify can always see exactly what the assistant received. The fallback and the vigilance path are the same mechanism, so the worst case is always today's behavior.
Scope
Local tools only. MCP tools have no command to re-invoke for rewriting, matching how custom argument formatters already work (they exist only for command tools).
Drawbacks
- It breaks an invariant. Today the terminal is a faithful (if abbreviated) view of what the assistant received. Tool-authored divergence means the human's screen is no longer guaranteed to equal the assistant's input. This is deliberate, gated, and opt-in, but it is a real loss.
- A third action for tool authors. Every local tool author now has one more
Actionvariant to be aware of, even if only to ignore it. - Storage of a second large blob for divergent results. Mitigated by store-only-on-divergence, and not novel given existing rendered-argument storage.
Alternatives
- Re-derive the display view at render time (don't store). Rejected. JP already faced this fork for
FormatArgumentsand chose store-once, replay-from-storage. Re-deriving makes historical display depend on the tool still existing and behaving deterministically, and forces a tool spawn per result atjp conversation printtime. Storing is reproducible and keeps replay synchronous. - JP-side projection only (no tool-authored divergence). Rejected. A deterministic JP projection cannot express content-level divergence (a terse summary) and cannot strip a tool-specific gutter generically.
- Put the opt-in under
access. Rejected.accessis capability scope; this is representation. Forcing representation into a capability-grant model would be the wrong abstraction (see RFD 076).
Non-Goals
- Per-frontend rendering. Serving distinct renderings to web / native / TUI frontends is deferred. The stored model bakes in one rendering; when multiple frontends land, the stored data is migrated and non-terminal frontends can re-derive at that point. This is the one case that would argue for re-derivation, and it is not needed yet.
- Caching beyond the stored view. Out of scope.
- MCP tool divergence. Out of scope; the rewrite action is local-only.
Risks and Open Questions
- Security: divergence can blind the auditor. A divergent display lets a tool show the user benign content while feeding the assistant something else — a prompt-injection blind spot. The threat is contained by three properties: local-tools-only, default-off opt-in, and the assistant-view escape hatch (
result = "ask"). Third-party local command plugins are still less-trusted code, so the opt-in flag is the gate. A doc comment warns honest authors against gratuitous divergence (paste-back confusion); the gate, not the comment, handles dishonest or compromised tools. - Action naming.
RewriteResultcaptures that the content may differ;FormatResultreads more parallel toFormatArgumentsbut understates the divergence. Naming is open. - Context shape for the new action. What the rewrite invocation receives — the assistant view, the original arguments, or both — needs to be pinned down.
- Pipeline reuse. Confirm the new action reuses
run_tool_commandand theRenderOutcome::Rendered { content }→ drain → event-metadata path verbatim rather than growing a parallel one.
Implementation Plan
Phase 1: Action variant, turn-time invocation, storage
Add the RewriteResult variant to jp_tool::Action. Invoke it once at turn time after Run, reusing the custom-formatter machinery (format_args_custom → run_tool_command). Store the display view in tool-call event metadata, mirroring rendered_arguments. Replay reads it from metadata.
Mergeable independently. No behavioral change until a tool opts in.
Phase 2: Configuration and gating
Add the default-off * config field on conversation::tool. Gate invocation on it, so the action is only sent to opted-in tools (capability declaration).
Depends on Phase 1.
Phase 3: Fallback, vigilance, and the author contract
Wire result = "ask" to render the assistant view directly, and document the divergence contract for tool authors (the doc-comment warning).
Depends on Phases 1-2. Independent of each other within the phase.
References
- RFD 048: Four-Channel Output Model — JP's process output channels; a distinct axis from this RFD.
- RFD 036: Conversation Compaction — precedent for extending the
Actionenum with a new variant. - RFD 058: Typed Content Blocks for Tool Responses — a possible future home for an audience tag if the string-field model proves too narrow.
- RFD 076: Tool Access Grants — why the opt-in belongs on tool representation config, not
accessscope. crates/jp_cli/src/render/tool.rs—format_args_custom,render_approved,render_formatted_arguments.crates/jp_cli/src/cmd/query/tool/coordinator.rs—rendered_argumentsdrain into event metadata.crates/jp_tool/src/lib.rs—Actionenum.