Repository navigation
Open
Description
Feature hasn't been suggested before.
- I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
Use case
Some plugins have the model write small markers into its own replies. For example, a context-management plugin asks the model to tag messages with §N§ so it can refer back to them. These markers are meant for the plugin, not the user. On v1, experimental.text.complete lets a plugin rewrite an assistant text part when it completes, before it is saved, so the markers never reach the stored transcript or the UI.
On v2 there is no equivalent, so the markers are saved in session_message and shown in the timeline.
What v2 offers today (checked at upstream/v2 d1f8f5b, same as v2.0.22)
SessionHooks(packages/plugin/src/promise/session.ts) isprompt,context,compaction,generate,title,model.request,http.request,http.response,experimental.ws.handshake,experimental.ws.send,experimental.ws.receiveandretry. Nothing runs between the parsed model reply and persistence, or before rendering.- The TUI plugin API's only markdown seam is
markdown.registerCodeBlockRenderer(language, render)(packages/plugin/src/tui/context.ts), which handles fenced code blocks by language, not prose. The slot map has no per-message or per-part slot. - The closest workaround is to replace the provider's raw response in
http.response(or edit frames inexperimental.ws.receive) and strip the markers from the wire stream before OpenCode parses it. That works, but it is provider-specific: a marker can be split across SSE events or JSON-escaped deltas, and every protocol (Anthropic, OpenAI Responses, Gemini, ...) needs its own parser.
Proposal (either would cover it; the first is preferred)
- A display hook that runs before assistant text is rendered, in the TUI and the app, letting a plugin hide its own markers from what the user sees. The stored text and what is sent back to the model stay exactly as the provider returned them. This is the better fit because it doesn't change history: prompt-cache prefixes stay byte-stable, and the WebSocket continuation path, which compares stored output byte for byte, is unaffected.
- A
text.completeequivalent: a session hook that runs when an assistant text part completes, with the part text mutable, before it is persisted. This is what v1 does today.
Related issues
These cover nearby ground on v1 and are closed, so none of them tracks v2:
- Add TUI display-only chat text transform hook (string→string) #11864 (TUI display-only text transform hook): closed automatically after 90 days without activity, not implemented.
- [FEATURE] Plugin hook for streaming text transformation (e.g.
text.delta) #39526 (streamingtext.deltatransform hook): closed as not planned. - experimental.text.complete hook output not reflected in TUI display #7287: a v1 bug where
experimental.text.completeoutput did not reach the TUI; fixed in v1, and that hook has no v2 equivalent.
Reacted by Fedor Suchkov, null-axiom, Yosua Ian Sebastian, pieliesdie, tamasys, tobwen, Jermiah Joseph, lexomis, Retaw, Jeremy McCoy and 3 more
Metadata
Metadata
Assignees
Labels
No labels
Activity
[-]Plugins have no way to transform assistant text for display (or on completion) on v2[/-][+][FEATURE]: Plugins have no way to transform assistant text for display (or on completion) on v2[/+]github-actions commentedon Oct 4, 2026
Thanks for updating the issue!
tobwen commentedon Oct 11, 2026
This is really important, as the markers destroy OpenCode's ability to recognize Markdown correctly.
circularnatural commentedon Oct 14, 2026
Another use case that breaks on v2: output validation and repair, not just marker stripping.
I run a small plugin that audits every completed assistant reply against a fixed closing format
(required section headers, their order, and evidence lines when the reply claims something was
tested) and repairs it in place before it is persisted. On v1 this is a few lines on
experimental.text.complete: readoutput.text, rewrite it, log what was fixed.On v2.0.28 the plugin loads (dual
setup/serverentrypoint), but the V2 path has to stay a no-op:none of the
SessionHooksrun between the parsed reply and persistence, and rewriting the wirestream in
http.responsewould mean a per-provider SSE parser for a text-level check.Either option from the proposal would cover it. A completion-time hook would be enough for us,
since the repair has to reach the stored transcript, not just the rendered view, e.g.:
with
sessionID,messageIDandpartIDon the event, like v1.docs: defer p3-opencode-titles
Trollgeir commentedon Oct 25, 2026
Another related use case: display-only transcript projection for context-management plugins.
Lacuna replaces archived conversation content with summaries in outgoing model requests, while keeping stored session history unchanged. The UI still shows every original turn, so the visible conversation no longer reflects the model’s active context.
Could the display-only proposal extend beyond transforming individual text parts to changing how transcript content is presented? For Lacuna, that would mean grouping archived messages and tool results into expandable summary blocks.
Importantly, expanding a block would reveal the originals to the user only, without restoring them into model context. Stored history would remain untouched, and disabling the plugin would return the normal transcript.
A text-only hook wouldn’t cover this, but a more general presentation hook could support grouping, annotations, and other plugin use cases too.
opencodejr commentedon Oct 27, 2026
Thanks for the detailed write-up, @ualtinok!
To restate the request: in v2, plugins have no way to change assistant text before it is shown or saved. That means plugin-internal markers such as
§N§end up in the stored transcript and the timeline. You'd like either a display-only transform hook (preferred, since it leaves the stored text and the model context unchanged) or a session hook like v1'sexperimental.text.complete.I checked the current v2 plugin API and agree there's a gap.
SessionHookshas nothing that runs between the parsed model reply and saving it. On the TUI side,markdown.registerCodeBlockRendereronly handles fenced code blocks. Rewriting the stream inhttp.response/experimental.ws.receiveis the only workaround today, and it needs a separate parser for each provider. So this can't be built cleanly as a plugin right now, and I've passed the two options on to the maintainers to consider.Related issues (all v1 and closed, none of them tracks v2):
text.delta) #39526: streamingtext.deltatransform hookexperimental.text.completeoutput not reaching the TUI on v1luanweslley77 commentedon Nov 3, 2026
Another data point from a v1 plugin author, because the plugin name here is misleading: I maintain a formatting plugin whose scope is not just Markdown tables. It normalizes text artifacts across three different surfaces, and all three lose their seam on v2.
What the plugin actually does
:---:,---:), minimum width, and for.mdfiles a column-shrink + line-wrap pass so a wide table fits the editor window (231-col default; idempotent).┬/┴/┼), multi-cell spans, and routes arrows (▼/▲) with 90° elbows to the target box center, fills stems, aligns horizontal arrows/ports, and fixes fishbone/timeline axes and git-graph pipes.CJK=2, emoji=2, box-drawing=1) and editor/.md(CJK=5/3,▶=2with Noto Sans Mono, measured from system fonts viafc-match+ SFNT parsing).How it hooks v1 today
experimental.text.complete— rewrites the finished assistant text before it is stored (visualmodel).event: file.edited— re-reads the.mdfile and writes theraw-formatted result back to disk.event: message.part.updated(reasoning parts) —text.completedoes not fire for thinking, so the plugin formats the stored reasoning part and patches it through the internal HTTP client (client._client.patch(.../part/...)).What v2 lacks for this plugin
context/display transform would leave reasoning unhandled.file.editedevent (the v2 event list has onlysession.*), so the on-disk.mdformatting has no trigger.markdown.registerCodeBlockRendereronly covers fenced code, not tables or box-drawing prose.The only v2 workaround mentioned in the issue (rewriting
http.responseorexperimental.ws.receive) would require a separate stream parser per provider (Anthropic/OpenAI/Gemini/...), which is exactly the kind of lifecycle assumption a plugin should not have to preserve.Question for maintainers: is the proposed display-only transform scoped to assistant text only, or could it cover (a) reasoning parts before persistence and (b) edited
.mdfiles? And is a public "update stored part" API planned as part of that, so plugins don't need internal HTTP patches?tobwen commentedon Nov 4, 2026
In my own fork, I've kept the markers, since it's a reference when looking up things in exported markdown files or inside the opencode-db. I've just thrown them on a seperate line and muted the color. So it doesn't interfere when rendering markdown.
Maybe I'll add a way to collapse them...