Skip to content

feat(tui): color plugin and MCP tool output by language - #54968

Closed
Nowaker wants to merge 2 commits into
anomalyco:v1from
Nowaker:tool-output-syntax
Closed

Nowaker wants to merge 2 commits into
anomalyco:v1from
Nowaker:tool-output-syntax

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 12, 2026

Copy link
Copy Markdown

Note: I'm aware v1 is now the maintenance branch and new features go to v2 only. This PR is here for others to use, and I'll submit the v2 version once I've migrated my whole plugin system to it.

Issue for this PR

Closes #54957

Builds on the bash command coloring PR (#54704), whose highlight cache and non-red tool-code palette it uses; its commit is the first one here. Review only the second commit.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Generic tool output (plugin and MCP tools, when output is shown) is drawn as plain text, and nothing knows its type: a plugin tool returns a string, and an MCP text result carries no content type. A JSON result reads as a grey wall.

  • syntax.tool_output in tui.json maps a tool name or glob (browser, github_*, *) to a language id, auto or none. An exact name wins over globs, and the most specific glob over the rest (util/tool-output.ts). Unset by default, so output stays plain.
  • auto colors output that is a JSON object or array: a bounded sniff (bracket pair, at most 100,000 characters, one parse), cached by content. Over 564 real generic outputs it costs about 6 µs per output cold and 2 µs warm.
  • The output is one text renderable whose content becomes the same text as colored chunks, so it wraps exactly as the plain text does. It uses the non-red tool-code palette from feat(tui): color the bash tool's command as shell code #54704, since red means a failed tool call.
  • A collapsed preview cuts through the middle of a JSON value and does not parse, so the whole output is highlighted once (cached, in the parser worker) and the preview takes the highlights inside the part it shares with the output.

Possible follow-up: let a tool declare its output's content type, so no config is needed. For example a plugin tool({ ..., output: { contentType: "application/json" } }), or an MCP result's mimeType, mapped to a language before syntax.tool_output is consulted.

How did you verify your code works?

  • bun typecheck in packages/tui and packages/opencode; bun test test/util test/config.test.tsx test/cli/tui test/tool-code-theme.test.ts: 129 pass. tool-output.test.tsx compares plain and colored frames at 40, 80 and 160 columns; unit tests cover the glob precedence, the sniff and the preview highlights.
  • A real TUI at 80 and 160 columns with a browser JSON result and a plain-text result under "*": "auto": the screen text is byte-identical with the setting on and off; JSON keys, strings and numbers are colored in the collapsed preview; the plain-text result stays plain.
  • A/B/B/A on a copied 5,600-part session with 86 JSON results, shown output, through a PTY client: scroll and keystroke latency, including keystrokes while the colors land, stay within run-to-run spread; idle CPU unchanged.

Screenshots / recordings

p/els keys in the property color, "/console/..." in the string color:

# browser [action=run, name=main]
{
  "p": "/console/u/0/developers/.../users-and-permissions/invite",
  "els": [
…

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

This PR is AI generated, but under direct supervision and on request of @Nowaker.

The bash tool's command was the one block of code in a session drawn as
plain text, while write, edit and apply_patch already go through
tree-sitter. It is now the same `<text>$ command</text>` as before, with
each token a colored span, so it wraps, indents and fits the block
exactly as the plain command does; only the colors differ.

The text appears plain at once and takes its colors when the parser
worker answers. Highlighting goes through a cache keyed by language and
content (packages/tui/src/util/highlight-cache.ts), so a remounted
transcript or a reopened session does not reparse every command.
Identical concurrent requests share one round trip, a failed one is
retried, and the cache holds at most 1000 entries or 4M characters.

In the transcript red means a failed or rejected tool call, so the
command is colored with a syntax style that never uses red
(packages/tui/src/theme/tool-code.ts): every syntax slot that comes out
red, or in the theme's error color, takes a non-red alternative, such as
the function color for variables, commands and builtins. A test checks
every built-in theme in dark and light.

nvim-treesitter's bash query narrows its number, constant and shebang
captures with `#lua-match?`, which web-tree-sitter does not evaluate, so
every word came back as a number and every variable as a constant.
packages/tui/src/util/bash-highlight.ts applies those patterns after the
worker answers. It also drops the query's `@spell` captures: the theme
colors spell as plain text, which turned every comment back to the text
color.

`syntax.bash: false` in tui.json restores the plain-text command.
Generic tool output was drawn as plain text, and nothing knows its
type: a plugin tool returns a string and an MCP text result carries no
content type, so a JSON result from `browser` read as a grey wall.

`syntax.tool_output` in tui.json maps a tool name or glob (`browser`,
`github_*`, `*`) to a language id, `auto` or `none`; an exact name wins
over globs and the most specific glob over the rest
(packages/tui/src/util/tool-output.ts). `auto` colors output that is a
JSON object or array: a bounded sniff (bracket pair, at most 100,000
characters, one parse) whose answer is cached by content. Only output
that is shown is sniffed or colored. Unset by default, so output stays
plain as upstream draws it.

The output is one text renderable whose content becomes the same text
as colored chunks when the parser worker answers, so it wraps exactly
as the plain text does (test/cli/tui/tool-output.test.tsx compares the
frames at 40, 80 and 160 columns), and it uses the non-red tool-code
palette, since red means a failed tool call.

The whole output is highlighted, once and cached, even while the block
is collapsed: a preview cut through the middle of a JSON value does not
parse, so the preview takes the whole output's highlights that fall
inside the part it shares with the output, and its ellipsis stays plain.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Nowaker added a commit to Nowaker/opencode that referenced this pull request Oct 12, 2026
syntax.tool_output colors plugin and MCP tool output by tool name or
glob, with a cached JSON sniff for auto (0ab6637); issue anomalyco#54957, PR
anomalyco#54968; the plugin SDK content-type idea is kept as a future note;
vt-252 on desktop and m4max.

AI-Model: anthropic/claude-opus-5-5
AI-Variant: high
AI-Tool: opencode 1.19.0-vt-227-dfb56f76c7
AI-Platform: linux
AI-Harness: Vibeterm e6434eb
AI-Session-ID: ses_ed1e2b932ffe6tTqhsOiGuYXRE
@Nowaker

Nowaker commented Nov 2, 2026

Copy link
Copy Markdown
Author

Closing: this targets the v1 TUI, which upstream now maintains for critical fixes only, so it is not going to be merged. The branch stays on Nowaker/opencode if anyone wants to pick it up.

@Nowaker Nowaker closed this Nov 2, 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