Skip to content

An experimental Markdown renderer#597

Open
xenodium wants to merge 31 commits into
mainfrom
inline-markdown
Open

An experimental Markdown renderer#597
xenodium wants to merge 31 commits into
mainfrom
inline-markdown

Conversation

@xenodium
Copy link
Copy Markdown
Owner

@xenodium xenodium commented May 20, 2026

Experimenting with inline text properties to render Markdown text into agent-shell buffers.

agent-shell's Markdown renderer today is powered by overlays. While overlays have served us well for some time, they have some limitations, primarily around performance (so far has been good enough-ish) but also with text navigation/selection.

This branch is an experiment to see if we can achieve an improved experience around Markdown rendering using inlined text properties (no overlays).

Two initial areas of focus:

Table navigation and content selection (not currently possible in today's overlay implementaion)

2026-05-20-19:13:40-Emacs_optimized

Cell navigation

  • M-x agent-shell-markdown-table-next-cell
  • M-x agent-shell-markdown-table-previous-cell

More performant code block rendering

image

Enabling experimental renderer

To try out the experimental renderer in this branch use:

(setq agent-shell--experimental-renderer t)

Report bugs

This is fairly experimental, so please do report bugs.

@mplanchard
Copy link
Copy Markdown

Seems work nicely! If your lazy-highlight face is less distracting than the gray one that you have, it looks a bit much:

image

I'm using the modus-vivendi-deuteranopia theme. Easy enough to change this particular face, but it would be nice to have a built in option to not highlight it or something.

@mplanchard
Copy link
Copy Markdown

Oh yeah it also seems like I'm not getting the fancy table behavior described, it's just showing the markdown table.

@mplanchard
Copy link
Copy Markdown

Oh yeah it also seems like I'm not getting the fancy table behavior described, it's just showing the markdown table.

Ah, it seems to work with claude, but not codex, is the thing

@xenodium
Copy link
Copy Markdown
Owner Author

Oh yeah it also seems like I'm not getting the fancy table behavior described

@mplanchard that's because the LLM embedded it in a code block. Ask it to give you the table without a code fences.

@mplanchard
Copy link
Copy Markdown

@mplanchard that's because the LLM embedded it in a code block. Ask it to give you the table without a code fences.

eyyy yeah that does it, although the heading alignment is a bit funky

image

@xenodium
Copy link
Copy Markdown
Owner Author

Ah thanks. I'll need to see the traffic that generated the Markdown to look into it. https://github.com/xenodium/agent-shell?tab=readme-ov-file#how-do-i-viewget-agent-client-protocol-traffic

@xenodium
Copy link
Copy Markdown
Owner Author

If your lazy-highlight face is less distracting than the gray one that you have, it looks a bit much

Good to know. I'll see if I can find a more subtle default. Having said that, the new renderer offers faces that can be overriden.

@mplanchard
Copy link
Copy Markdown

Ah thanks. I'll need to see the traffic that generated the Markdown to look into it. https://github.com/xenodium/agent-shell?tab=readme-ov-file#how-do-i-viewget-agent-client-protocol-traffic

Roger, here you go

traffic output
13:14:13.690 → request      session/list
13:14:13.692 → request      session/prompt
13:14:13.754 ← response     result
13:14:15.454 ← notification session/update
13:14:15.455 ← notification session/update
13:14:15.482 ← notification session/update
13:14:15.483 ← notification session/update
13:14:15.514 ← notification session/update
13:14:15.515 ← notification session/update
13:14:15.548 ← notification session/update
13:14:15.572 ← notification session/update
13:14:15.572 ← notification session/update
13:14:15.593 ← notification session/update
13:14:15.594 ← notification session/update
13:14:15.620 ← notification session/update
13:14:15.639 ← notification session/update
13:14:15.658 ← notification session/update
13:14:15.677 ← notification session/update
13:14:15.724 ← notification session/update
13:14:15.748 ← notification session/update
13:14:15.749 ← notification session/update
13:14:15.770 ← notification session/update
13:14:15.770 ← notification session/update
13:14:15.796 ← notification session/update
13:14:15.797 ← notification session/update
13:14:15.838 ← notification session/update
13:14:15.856 ← notification session/update
13:14:15.875 ← notification session/update
13:14:15.876 ← notification session/update
13:14:15.897 ← notification session/update
13:14:15.918 ← notification session/update
13:14:15.938 ← notification session/update
13:14:15.958 ← notification session/update
13:14:15.977 ← notification session/update
13:14:15.996 ← notification session/update
13:14:16.112 ← notification session/update
13:14:16.113 ← notification session/update
13:14:16.115 ← notification session/update
13:14:16.118 ← notification session/update
13:14:16.120 ← notification session/update
13:14:16.123 ← notification session/update
13:14:16.149 ← notification session/update
13:14:16.149 ← notification session/update
13:14:16.182 ← notification session/update
13:14:16.221 ← notification session/update
13:14:16.222 ← notification session/update
13:14:16.224 ← notification session/update
13:14:16.227 ← notification session/update
13:14:16.254 ← notification session/update
13:14:16.328 ← notification session/update
13:14:16.329 ← notification session/update
13:14:16.332 ← notification session/update
13:14:16.334 ← notification session/update
13:14:16.361 ← notification session/update
13:14:16.362 ← notification session/update
13:14:16.365 ← notification session/update
13:14:16.392 ← notification session/update
13:14:16.419 ← notification session/update
13:14:16.420 ← notification session/update
13:14:16.449 ← notification session/update
13:14:16.473 ← notification session/update
13:14:16.474 ← notification session/update
13:14:16.514 ← notification session/update
13:14:16.515 ← notification session/update
13:14:16.551 ← notification session/update
13:14:16.552 ← notification session/update
13:14:16.585 ← notification session/update
13:14:16.587 ← notification session/update
13:14:16.619 ← notification session/update
13:14:16.620 ← notification session/update
13:14:16.657 ← notification session/update
13:14:16.659 ← notification session/update
13:14:16.682 ← notification session/update
13:14:16.683 ← notification session/update
13:14:16.713 ← notification session/update
13:14:16.713 ← notification session/update
13:14:16.748 ← notification session/update
13:14:16.773 ← notification session/update
13:14:16.793 ← notification session/update
13:14:16.935 ← notification session/update
13:14:16.936 ← notification session/update
13:14:16.948 ← notification session/update
13:14:16.952 ← notification session/update
13:14:16.956 ← notification session/update
13:14:16.960 ← notification session/update
13:14:16.963 ← notification session/update
13:14:16.967 ← notification session/update
13:14:16.996 ← notification session/update
13:14:16.997 ← notification session/update
13:14:17.000 ← notification session/update
13:14:17.037 ← notification session/update
13:14:17.037 ← notification session/update
13:14:17.041 ← notification session/update
13:14:17.045 ← notification session/update
13:14:17.068 ← notification session/update
13:14:17.090 ← notification session/update
13:14:17.112 ← notification session/update
13:14:17.114 ← notification session/update
13:14:17.144 ← notification session/update
13:14:17.173 ← notification session/update
13:14:17.175 ← notification session/update
13:14:17.217 ← notification session/update
13:14:17.258 ← notification session/update
13:14:17.286 ← notification session/update
13:14:17.287 ← notification session/update
13:14:17.397 ← notification session/update
13:14:17.403 ← notification session/update
13:14:17.430 ← notification session/update
13:14:17.431 ← notification session/update
13:14:17.435 ← notification session/update
13:14:17.439 ← notification session/update
13:14:17.443 ← notification session/update
13:14:17.447 ← notification session/update
13:14:17.451 ← notification session/update
13:14:17.455 ← notification session/update
13:14:17.491 ← notification session/update
13:14:17.492 ← notification session/update
13:14:17.495 ← notification session/update
13:14:17.526 ← notification session/update
13:14:17.528 ← notification session/update
13:14:17.532 ← notification session/update
13:14:17.557 ← notification session/update
13:14:17.559 ← notification session/update
13:14:17.583 ← notification session/update
13:14:17.715 ← notification session/update
13:14:17.717 ← notification session/update
13:14:17.722 ← notification session/update
13:14:17.725 ← notification session/update
13:14:17.729 ← notification session/update
13:14:17.767 ← notification session/update
13:14:17.768 ← notification session/update
13:14:17.772 ← notification session/update
13:14:17.777 ← notification session/update
13:14:17.781 ← notification session/update
13:14:17.813 ← notification session/update
13:14:17.815 ← notification session/update
13:14:17.840 ← notification session/update
13:14:17.864 ← notification session/update
13:14:17.866 ← notification session/update
13:14:17.870 ← notification session/update
13:14:17.915 ← notification session/update
13:14:17.916 ← notification session/update
13:14:17.947 ← notification session/update
13:14:17.948 ← notification session/update
13:14:18.087 ← notification session/update
13:14:18.089 ← notification session/update
13:14:18.094 ← notification session/update
13:14:18.100 ← notification session/update
13:14:18.105 ← notification session/update
13:14:18.109 ← notification session/update
13:14:18.114 ← notification session/update
13:14:18.119 ← notification session/update
13:14:18.152 ← notification session/update
13:14:18.153 ← notification session/update
13:14:18.156 ← notification session/update
13:14:18.195 ← notification session/update
13:14:18.196 ← notification session/update
13:14:18.224 ← notification session/update
13:14:18.226 ← notification session/update
13:14:18.231 ← notification session/update
13:14:18.384 ← notification session/update
13:14:18.387 ← notification session/update
13:14:18.392 ← notification session/update
13:14:18.398 ← notification session/update
13:14:18.403 ← notification session/update
13:14:18.469 ← response     result
13:14:18.475 → request      session/list
13:14:18.540 ← response     result
full shell output
Codex> same output but not in a code block

▼ Notices

�[2m2026-05-22T17:14:13.691285Z�[0m �[31mERROR�[0m �[2mcodex_core::rollout::recorder�[0m�[2m:�[0m Falling back on rollout system

| Col 1 | Col 2 | Col 3 | Col 4 | Col 5 |
├────────┼───┼───┼───┼───┤
│ Row 1 │ A │ B │ C │ D │
│ Row 2 │ A │ B │ C │ D │
│ Row 3 │ A │ B │ C │ D │
│ Row 4 │ A │ B │ C │ D │
│ Row 5 │ A │ B │ C │ D │
│ Row 6 │ A │ B │ C │ D │
│ Row 7 │ A │ B │ C │ D │
│ Row 8 │ A │ B │ C │ D │
│ Row 9 │ A │ B │ C │ D │
│ Row 10 │ A │ B │ C │ D │

@xenodium
Copy link
Copy Markdown
Owner Author

thanks! almost there...

The buffer that has this:

13:14:13.690 → request      session/list
13:14:13.692 → request      session/prompt
13:14:13.754 ← response     result
13:14:15.454 ← notification session/update
13:14:15.455 ← notification session/update
13:14:15.482 ← notification session/update
13:14:15.483 ← notification session/update

Press C-x C-s (acp-traffic-save-to) to get the actual content of each one of those items

@mplanchard
Copy link
Copy Markdown

Oh lol, yeah, I thought that output didn't seem very useful. It's okay, one of these days I'll learn to read.

agent-shell-traffic.txt

weird that GH doesn't let you upload .el files, so I made it .txt

@xenodium
Copy link
Copy Markdown
Owner Author

Thanks that helps. Made some changes. Mind trying it out and see if you still have issues with headers on Codex?

@mplanchard
Copy link
Copy Markdown

Thanks that helps. Made some changes. Mind trying it out and see if you still have issues with headers on Codex?

Yeah that seems to have done the trick!

image

@xenodium
Copy link
Copy Markdown
Owner Author

Awesome. Thanks for reporting back!

@liaowang11
Copy link
Copy Markdown
Contributor

i'm testing this branch, but all tool use are expanded
PixPin_2026-05-24_22-49-39

@xenodium
Copy link
Copy Markdown
Owner Author

xenodium commented May 24, 2026

i'm testing this branch, but all tool use are expanded

@liaowang11 oh. interesting. i've not reproduced this yet myself. coincidentally, it looks like the bash section/node icon is collapsed. what happens when you expand it?

@liaowang11
Copy link
Copy Markdown
Contributor

liaowang11 commented May 25, 2026

i'm testing this branch, but all tool use are expanded

@liaowang11 oh. interesting. i've not reproduced this yet myself. coincidentally, it looks like the bash section/node icon is collapsed. what happens when you expand it?

I let codex to find the root cause using your emacsclient skill(super useful!) and here are the findings:

agent-shell--experimental-renderer breaks folding state for collapsed tool-call bodies.

Observed behavior:
Collapsed tool-call fragments show a folded button/indicator, but the body content is still visible. I confirmed this in both the shell buffer and the viewport buffer: fragment state has :collapsed t, while the body text itself has invisible nil.

Root cause:
agent-shell-ui applies folding by putting invisible t on the body region for collapsed fragments. With agent-shell--experimental-renderer enabled, markdown rendering goes through agent-shell-markdown-replace-markup, which destructively rewrites buffer text using delete-region/insert. Those rewrite paths preserve markdown styling props, but do not preserve fragment UI props like invisible and related agent-shell-ui-* properties. Tool calls reproduce this reliably because their body often starts with a fenced console block, which is rewritten by the experimental markdown renderer.

Recommended fix:
Make the experimental markdown renderer preserve UI properties across destructive rewrites, especially invisible and fragment section/state props on collapsed bodies. The safest fix is to capture the relevant properties from the replaced region and reapply them to the inserted text in markdown rewrite helpers, especially fenced code block rendering. Add a regression test that creates a collapsed tool-call fragment, runs the experimental renderer on its body, and asserts the body remains invisible.

@jcubic
Copy link
Copy Markdown

jcubic commented May 25, 2026

I would like to add a feature request related to the leaking of Markdown code when copy/pasting.

It would be nice to be able to influence the copy of the markdown. Both have it's use:

  • Copy markdown so you can paste into a Markdown file with formatting
  • Copy just the text so you can use it in Terminal. I often operate on text from the Agent-shell buffer (Elisp functions, filenames, etc.). When I copy the name, I get ** and ` after the selected text.

After thinking about it, stripping markdown is not a solution to the problem. You need to be able to control and switch between the two modes.

Two Elisp functions that can be used by users. It is probably the best solution. And one variable that holds default behavior.

This is just an idea.

@xenodium
Copy link
Copy Markdown
Owner Author

It would be nice to be able to influence the copy of the markdown

@jcubic this will be theoretically possible with the current approach. The text in the buffer, while it has the markdown stripped, it has metadata which can be used to regenerate markup. This has the advantage of being markup agnostic, so we can potentially have commands to "copy as Markdown" or "copy as Org", but copying always defaults to stripped text, which is the primary use that's often tripped (copy and paste in shell).

@xenodium
Copy link
Copy Markdown
Owner Author

xenodium commented May 25, 2026

I let codex to find the root cause using your emacsclient skill(super useful!)

@liaowang11 Nice to hear! It's very handy that it can inspect buffers for you, get text properties, etc.

agent-shell--experimental-renderer breaks folding state for collapsed tool-call bodies.

Observed behavior: Collapsed tool-call fragments show a folded button/indicator, but the body content is still visible. I confirmed this in both the shell buffer and the viewport buffer: fragment state has :collapsed t, while the body text itself has invisible nil.

I can't reproduce neither manually nor with a test: https://gist.github.com/xenodium/c3f9ce717eef920ccf289971ec968e0c

Could you please manually reproduce in your with some data you don't mind sharing? Post

@liaowang11
Copy link
Copy Markdown
Contributor

liaowang11 commented May 25, 2026

I let codex to find the root cause using your emacsclient skill(super useful!)

@liaowang11 Nice to hear! It's very handy that it can inspect buffers for you, get text properties, etc.

agent-shell--experimental-renderer breaks folding state for collapsed tool-call bodies.
Observed behavior: Collapsed tool-call fragments show a folded button/indicator, but the body content is still visible. I confirmed this in both the shell buffer and the viewport buffer: fragment state has :collapsed t, while the body text itself has invisible nil.

I can't reproduce neither manually nor with a test: https://gist.github.com/xenodium/c3f9ce717eef920ccf289971ec968e0c

Could you please manually reproduce in your with some data you don't mind sharing? Post

This seems to only happen with pi-agent, i don't encounter the problem with codex-acp.

The screenshot after model output:
PixPin_2026-05-26_00-17-13

After toggle:
PixPin_2026-05-26_00-17-28

After toggle again:
PixPin_2026-05-26_00-17-48

The traffic log:
https://gist.github.com/liaowang11/10bfaa59f2127970f3ffc52ec3a2ff08

agent-shell-tool-use-expand-by-default => nil
buffer-invisibility-spec => t

xenodium added a commit that referenced this pull request May 25, 2026
Reported in PR #597: pi-acp tool-call fragments rendered with the
indicator showing `▶' (collapsed) but the body fully visible.
xenodium added 22 commits May 30, 2026 17:50
Skip re-rendering already-processed prefix on each call

Streaming use of `agent-shell-markdown-replace-markup' calls the
renderer once per chunk, so every pass was re-walking the entire
buffer from `point-min' on each call — O(N^2) over N chunks.

Track a per-buffer "watermark": the position before which content
is fully rendered and stable.  Stored as an
`agent-shell-markdown-watermark' text property on the first
character (so a propertized string returned from
`agent-shell-markdown-convert' carries it without a buffer-local
variable).  Re-stamped at the end of each render to:
- start of the last line in the buffer; clamped back to
- start of any open fence (so a future closing ``` still matches),
- start of any rendered table whose extension is still possible
  (so streamed continuation rows still fold in).

The next call narrows to (watermark, point-max) and every pass
runs inside the narrow.

`:force' on `agent-shell-markdown-replace-markup' drops the
watermark and re-renders the whole buffer.
Reported in PR #597: pi-acp tool-call fragments rendered with the
indicator showing `▶' (collapsed) but the body fully visible.
@codeluggage
Copy link
Copy Markdown

codeluggage commented May 30, 2026

Hey - have been trying this out and although I've seen some beautiful things render, I've also seen, well, nothing render. Even when folded. It's been a journey to dig into it - here's a compressed explanation with some screenshots. Note that I'm using a new ACP I built myself and I understand there's different results for different ACPs but I think the issue is something that can be fixed in this branch.

Could be from 3651f82b because it flipped the trailing-ws hiding from overlays to text properties but did not add the rear-nonsticky mark (the destructive renderer that exposes the issue didn't exist yet).

These two screenshots are taken at the same point - notice that the tool call has not been unfolded. In between the screenshots I ran this:

(let ((inhibit-read-only t))
  (remove-text-properties (point-min) (point-max) '(invisible nil)))

Here's the before / after evaluating that elisp:

image image

A more sensible explanation than I'm able to give un-aided:


When agent-shell--experimental-renderer is t, a long tool-result body (e.g. a Read README.md returning the file contents) renders only the first heading; the rest of the body is in the buffer but invisible t. Repro: any ACP agent that returns a multi-section markdown file as a tool-call result, then expand the fragment.

Confirmed by (let ((inhibit-read-only t)) (remove-text-properties (point-min) (point-max) '(invisible nil))) — body reappears.

Root cause is two invisible t setters in agent-shell-ui--insert-fragment (agent-shell-ui.el L532-545) that don't add rear-nonsticky '(invisible). The destructive inline-markdown passes (delete-region + insert for headers, fences, tables) splice into the body and inherit invisible t from neighbor chars via rear-stickiness. The sibling apply-trailing-whitespace-invisible already documents and avoids this. Legacy markdown-overlays renderer is fine because it doesn't edit the buffer.

Likely fix: route the inline trailing-ws block through apply-trailing-whitespace-invisible and add rear-nonsticky '(invisible) to the whole-body collapse at L534-536.

@xenodium
Copy link
Copy Markdown
Owner Author

Thanks a lot for this. @codeluggage are you able to reproduce on the latest from branch (please also update shell-maker).

If you could also share the traffic file and a screenshot of the offending case, I'd be able to replay the traffic to render locally and investigate.

@codeluggage
Copy link
Copy Markdown

Buffer is still open - but alas, has a private repo in the mix so let me reproduce with another.

I believe I'm on the latest because I just got things brought up to date before posting this (results were the same as I was reproducing) but perhaps I am not on the latest shell-maker. Will check and give you a small repro with the nice back and forth arrows and the other log.

@xenodium
Copy link
Copy Markdown
Owner Author

@codeluggage In addition to eval what's listed in #597 (comment) do shell-maker.el too please

codeluggage added a commit to codeluggage/agent-shell that referenced this pull request May 30, 2026
The inline-markdown branch (xenodium#597) uses the
aligned-under-section style for `add-text-properties' plists, e.g.

    `(agent-shell-ui-section label-left
                             help-echo ,qualified-id
                             read-only t
                             front-sticky (read-only))

instead of the flat indent.  Reformatting our touched call sites
to match avoids gratuitous diff churn when the experimental
renderer lands upstream, and brings em-dashes back into the
folding-command docstrings to match the surrounding prose style.

No behaviour change.
@codeluggage
Copy link
Copy Markdown

@xenodium ha that was a confusing ride - turns out it was stale things locally for me in-between the recent force pushes. Confirmed fixed by cc9fea4 on the latest inline-markdown tip. I was on the old pre-force-push base (19a3c22). Now that I've rebased my fork onto 38b62b5 I thankfully can't reproduce. Unique situation for me as I put folding things (#608) on top of this branch.

@xenodium
Copy link
Copy Markdown
Owner Author

@codeluggage

Ah good to hear. Thanks for confirming.

Unique situation for me as I put folding things (#608) on top of this branch.

I'm hoping I can merge this branch soon, so should be ok for you. Having said that, I did force push to my branch so maybe that wasn't so fun for ya :/

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.

5 participants