Skip to content

Reader: render user prompts as markdown, in one band component - #107

Merged
lvwerra merged 1 commit into
mainfrom
feat/prompt-markdown
Aug 21, 2026
Merged

Reader: render user prompts as markdown, in one band component#107
lvwerra merged 1 commit into
mainfrom
feat/prompt-markdown

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 21, 2026

Copy link
Copy Markdown
Member

The reader rendered everything except the thing a person wrote. The answer has always gone through renderMarkdown; an aside, thinking and a compaction have since #88. The prompt band was raw text, so a typed **important**, a pasted fence or a list showed its syntax.

Screens (four prompt shapes, dark, phone, the Overview card, search, the optimistic echo): https://lvwerra-agent-artifacts.static.hf.space/prompt-markdown.html

What changed

PromptBand — one component, used by both call sites — renders the prompt through the answer's exact path, highlightHtml(renderMarkdown(text, { breaks: true }), q).

One band, not two. #77 was precisely the real turn and the optimistic echo drifting: written out by hand in two places, the echo lost the .cx wrapper whose padding cancels .cx-prompt's margin-left: -1.23em, and the newest prompt in every conversation hung a gutter's width left of the ones above it. Two copies of markdown-plus-highlight is that bug with more surface. pendingExchange.test.mjs now fails if a second hand-written band appears in the file, and if either call site stops using the component.

breaks: true, for the prompt only. A prompt is typed, not authored: its newlines are where someone pressed Enter, and the band showed every one of them as pre-wrap. Under strict markdown every multi-line prompt in the reader would have quietly reflowed into one paragraph — a loss of what was typed, not a rendering choice. An agent writes markdown deliberately, so an answer's single newline stays a soft wrap; a test reads the answer's own call to keep it that way.

The band keeps its geometry, and it is measured rather than argued. promptBand.render.test.mjs builds the real component for the browser — real marked, real DOMPurify — renders it inside the reader's own frame, and compares against the band as it was:

raw text rendered
text left edge 29.984375px 29.984375px
band left / right edge 14 / 640px 14 / 640px
one-line band height 30px 30px
four typed lines 75px 75px
queued one-liner 30px 30px
column scroll / client 626 of 612 626 of 612

The rendered prompt's text also starts on the same pixel as the answer below it, which is §3.2's one-column rule. The column overflows its client width by the gutter in both cases — the band is full-bleed by design, and that is the number a wide prompt must not move; my first version of that check asserted equality with clientWidth and was wrong about the baseline, not about the code.

How the heights hold:

  • .cx-pmd > :first-child / :last-child give up their margins, so a <p> does not add a fraction of a line to every turn in the reader.
  • The shared .markdown card's border, background, 14px and 1.6 are reset — it is a card elsewhere; here it is the band's own text at the band's own size.
  • The rendered block is an inline-block, so the queued pill still sits on the last line's baseline instead of dropping below it and making a queued one-liner a line taller than every other prompt.
  • Headings step to 1.05em and blocks space at 6px: the shared 22px h1 and 12px paragraph gaps read as a document dropped into a strip.

Mutation-checked, each against the assertions it should break: default last-child margin (2 fail), no inline-block (2), no max-width (3), no breaks (2), no font-size/line-height reset (2), no highlight (1), a fence that scrolls (1), a hand-written second band (2).

Two judgement calls, since the band was designed for one line of text

A fence in a prompt wraps; a fence in an answer scrolls. I looked at it before claiming it worked, and the first version scrolled — which on a phone hides the end of a pasted error behind a horizontal drag inside a slab. This band held the operator's own text as pre-wrap until now, so all of it was on screen; trading that for a swipe is the same mistake as putting something behind a hover. An answer's fence is code an agent wrote, where the line structure is the content and reflowing it lies about it; a prompt's fence is a paste. The divergence is written down where the rule lives.

Nothing is capped. A prompt with a 200-line paste makes a tall tinted band — but it already did as raw text, and the answer's own fences are uncapped too, so a cap here would be new behaviour rather than preserved behaviour. If it bites, it belongs on both.

One more thing worth stating: raw HTML in a prompt now renders as HTML. It goes through the same DOMPurify sanitiser as agent output, so a <script> or an onerror does not survive, but <div>hi</div> will draw a div where it used to show angle brackets.

State

Typecheck clean. 17 web suites, 3 browser suites (npm run test:render, which now includes this one), 24 server suites — all green. Branch off 3ce91a9.

The reader rendered everything except the thing a person wrote. The answer
always went through renderMarkdown; an aside, thinking and a compaction have
since #88; the prompt band was still raw text, so a typed `**important**`, a
pasted fence or a list showed its syntax.

It renders through the answer's exact path — highlightHtml(renderMarkdown(...)) —
so a search term lands in a rendered prompt the way it lands in a rendered
answer, rather than through a second mechanism that agrees with the first until
it doesn't.

ONE component, PromptBand, for both call sites. #77 was precisely the real turn
and the optimistic echo drifting: written out by hand twice, the echo lost the
`.cx` wrapper whose padding cancels `.cx-prompt`'s `margin-left: -1.23em` and the
newest prompt in every conversation hung a gutter's width left of the ones above
it. Two copies of markdown-plus-highlight is that bug with more surface.

`breaks: true`, for the prompt only. A prompt is typed, not authored: its
newlines are where someone pressed Enter, and the band showed every one of them
as pre-wrap. Under strict markdown every multi-line prompt in the reader would
have silently reflowed into one paragraph. An agent writes markdown deliberately,
so an answer's single newline stays a soft wrap.

The band keeps its geometry, measured rather than asserted. New
promptBand.render.test.mjs builds the real component for the browser — real
marked, real DOMPurify — renders it inside the reader's own frame and compares it
against the band as it was:

  text left edge   29.984375px → 29.984375px, and equal to the answer's below it
  band left/right  14 / 640px, unchanged: still full bleed
  one-line height  30px → 30px  (first and last child give up their margins)
  four typed lines 75px → 75px  (as three <br>, not one paragraph)
  queued one-liner 30px, pill on the last line's baseline, right of the words
  column overflow  626 of 612 either way — the band's own bleed, nothing new

Mutation-checked, each against the assertions it should break: default last-child
margin (2), no inline-block (2), no max-width (3), no `breaks` (2), no font-size/
line-height reset (2), no highlight (1), a fence that scrolls (1).

Two judgement calls worth stating, since the band was designed for one line:

- A fence in a prompt WRAPS where a fence in an answer scrolls. This band held
  the operator's own text as pre-wrap until now, so a pasted error was fully on
  screen; trading that for a horizontal swipe inside a slab is the same mistake
  as putting something behind a hover, and worst on a phone where the band is
  widest relative to the screen. An answer's fence is code an agent wrote, where
  the line structure is the content.
- Nothing is capped. A prompt with a 200-line paste makes a tall tinted band —
  but it already did as raw text, and the answer's own fences are uncapped too.
  A cap here would be a new behaviour, not a preserved one.

Headings step down to 1.05em and blocks space at 6px: a band is a strip in a
rhythm, and the shared .markdown card's 22px h1 and 12px paragraph gaps read as a
document dropped into the conversation.

Screens (plain, multi-line, fence and list, queued, search, dark, phone):
https://lvwerra-agent-artifacts.static.hf.space/prompt-markdown.html

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: APPROVE

Correctness

No correctness findings.

  • Geometry: the PR's browser measurements reproduce exactly: text left 29.984375px, band edges 14/640px, one-line height 30px, four typed lines 75px, and column width 626/612px. I also rendered first-child heading, list and fence blocks independently at 320px and 390px. The band reaches the same right edge in every case; .cxv-body and the page both retain zero horizontal scroll room, and the fence/table remain contained. The Overview card stays within its own 480px frame.
  • breaks: the live component emits one <br> for a prompt's single newline and none for the same newline in an answer. The source test would fail both obvious convergence mutations: passing { breaks: true } to the answer puts breaks in the guarded answer slice, and making the renderer unconditional removes the asserted breaks: !!opts?.breaks contract.
  • One component/two sites: PromptBand is the sole writer of className="cx-prompt"; both ExchangeView and PendingExchange call it. The count assertions fail if a normal second hand-written band is introduced or either call is removed, so they pin the #77 regression rather than merely observing current markup.
  • Search: independent browser probes put mark.cx-hit inside the original <code>, <a> and <h1> nodes, preserving each rendered structure. This is the same highlightHtml(renderMarkdown(...)) path as answers.
  • Safety: the prompt takes the existing renderMarkdown → DOMPurify path. In a live browser probe, <script> disappeared, onerror disappeared, a javascript: href was removed, and safe formatting remained; inserting the result did not execute anything.

Does it solve the operator's problem?

Yes. The one-line and typed-multiline cases keep the old rhythm, while lists, emphasis and code now read as structure instead of syntax. The phone fence wraps without widening the page, and the Overview card uses the same band cleanly. Of the four shapes, the fence/list is necessarily denser than raw text, but the study's treatment keeps it legible and in the band's visual register in both themes; I do not see a worse regression there.

Verification

  • Inspected the supplied reader, phone, Overview-card, search and optimistic-echo screens.
  • npm test: 17 suites pass.
  • npm run test:render: all 3 Chromium suites pass.
  • npm run typecheck and npm run build pass.

@lvwerra
lvwerra merged commit efb360e into main Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant