Skip to content

feat(tui): color file contents in tool output by each file's language - #56007

Open
Nowaker wants to merge 3 commits into
anomalyco:v1from
Nowaker:tui-file-output-syntax
Open

Nowaker wants to merge 3 commits into
anomalyco:v1from
Nowaker:tui-file-output-syntax

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 16, 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 #56006

Builds on #54968 (plugin and MCP tool output coloring), which builds on #54704 (bash command coloring). Their commits come first; review only the last commit.

Type of change

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

What does this PR do?

Reads and searches print code from files, and it is drawn plain. syntax.file_output (default off) colors it in each file's language:

  • Bash. When the command reads files of one language (cat, head, tail, sed -n, nl, awk, bat, git show <ref>:<path>, chained or piped through filters like head or grep), its output is colored whole. Lines that name their file (path:12:code, path-12-code, path:code, rg headings) are colored per file. A 12: gutter takes the command's language, and so does an rg -t py or --glob '*.rs'. Output that is a whole JSON document or starts like a diff is colored as such.
  • read and grep tools. Their rows can be clicked to show the file or the matches as a colored block, and clicked again to fold back.
  • Plugin and MCP output that syntax.tool_output leaves alone: GitHub code search snippets (grep_app) in the language of each Path:, docs with fenced code (context7) as markdown, and the line shapes above. A tool set to none stays plain.

Path, line-number and header prefixes are muted; red is never used. Output stays plain when the language is unclear: files of different languages read by one command, a command that also runs something else (ls && cat a.ts), or a pipe through a transform (cat a.json | jq).

Performance: the language is worked out once per finished call, never while it streams or per frame. Each file's lines are highlighted together once in the parser worker and cached by content, and the colors land on whatever part of them the shown text holds, so a collapsed preview is colored like the expanded output. Output over 100,000 characters stays plain, and only the first 64 files of a search are highlighted.

The design comes from ten days of my own tool history: 36,910 of 57,040 bash calls had a read or search stage, 19,673 of them naming files of a single language; there were 12,816 read calls and 873 grep calls.

How did you verify your code works?

  • bun typecheck in packages/tui; bun test in packages/tui: 252 pass. Unit tests cover command parsing (pipes, chains, quoting, --type/--glob, git show), every output shape, the read and grep tool formats, code search snippets, and mapping a collapsed preview back onto the output. Frame tests show colored output wraps byte-identically to the plain text at 40, 80 and 160 columns.
  • A real TUI (isolated config and database, throwaway tmux socket) at 80 and 160 columns with a grep -n over a .ts file, an rg --heading across .ts and .md, a sed -n of a .py file, a read of a .go file (clicked open) and a grep_app result: the code is in its language's colors, prefixes are muted, and the layout is unchanged.

Go string literals stay plain: nvim-treesitter's Go query captures them again as @spell, which has no style. That affects every Go view, file tools included, and is not changed here.

Screenshots / recordings

rg -n --heading output src, the path and 12: muted, the code colored as TypeScript and markdown:

src/util/file-output.ts
12:export function bashOutputModel(command: string, output: string) {
13:  if (!output) return

README.md
3:## Tool output

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.
Reads and searches print code from files, and it is drawn plain: a
`cat x.ts | sed -n 1,60p`, `grep -n`, an `rg` with headings, a GitHub
code search snippet. syntax.file_output (default off) colors it:

- Bash output is colored when the command reads files of one language
  (cat, head, tail, sed, nl, awk, bat, git show <ref>:<path>, chained
  or piped through filters), when it is a whole JSON document or a
  diff, and line by line where each line names its file: `path:12:`,
  `path-12-`, `path:`, rg headings, or a `12:` gutter on files of one
  language. A `--type` or `--glob` of one language counts too.
- The read and grep tools' rows can be clicked to show what they found,
  colored, and folded back.
- Plugin and MCP output that syntax.tool_output leaves alone: GitHub
  code search snippets by their Path, docs with fenced code as
  markdown, and the line shapes above. 'none' for a tool keeps it
  plain.

Prefixes (paths, line numbers, headers) are muted. Each file's lines
are highlighted together once, cached, and the colors land on whatever
part of them the shown text holds, so a collapsed preview is colored
like the expanded output. The language is worked out once per finished
call; output over 100,000 characters, past 64 files, or of no clear
language stays plain.
@github-actions

Copy link
Copy Markdown
Contributor

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

This branch has not been deployed

No deployments
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