Comview turns unified diffs into an interactive terminal review UI using vaxis, Chroma, cached syntax lexers, inline diff spans, and local review comments.

Comview: a Go TUI for reading diffs that actually makes sense


Comview is a terminal diff viewer. It reads unified diff text from stdin, parses it into rows, and gives you a review interface with scrolling, search, themes, side-by-side mode, comments, selections, text objects, and file jumping.

The repo is useful because it is small but not toy-sized. There is enough code to show how terminal programs get messy, and enough structure to see how the author keeps that mess contained.

The input model is simple

The README shows the intended usage:

git diff | comview
git show | comview
gh pr diff 123 | comview
comview watch
comview watch --staged
comview watch -- git show HEAD

That design choice matters. Comview does not need to be a GitHub client, a Git wrapper, and a pager all at once. cmd/comview/main.go either reads stdin or starts watch mode. comview watch reruns a diff-producing command and refreshes when the output changes.

There is a nice Go lesson there: accept plain text at the boundary when you can. If your program consumes unified diff format, git diff, git show, and gh pr diff can all feed it without extra integrations. That keeps the CLI flexible and the codebase smaller.

Parsing diffs into rows

The diff/ package owns the transformation from text to display data. diff.Parse scans the input, identifies file headers, hunk headers, context lines, additions, deletions, no-newline markers, and commit metadata. The scanner buffer is raised to 8 MiB, which is a small but practical detail for real diffs with long generated lines.

The parsed Document is then rendered into diff.Row values. That row model is what the TUI deals with:

  • file rows
  • hunk rows
  • context, add, delete, and no-newline rows
  • diff stats
  • commit headers and metadata
  • inline spans for changed parts of a line

That split is the right one. The terminal layer should not have to remember how unified diff headers work. It should receive rows with enough metadata to paint, navigate, search, and attach comments.

Inline changes use token-level matching

diff/inline.go is more interesting than a plain line parser. It pairs deleted and added rows, scores their similarity, and computes inline spans for the changed portions.

The implementation tokenizes each line and uses dynamic programming in two places. bestLinePairs finds plausible delete/add line pairs across a hunk. tokenLCSMatrix finds matched tokens inside a line pair. The result is more precise than marking the whole line red or green: it highlights the exact expression, argument, or identifier that changed.

The code is also careful about text. It uses utf8 and a small tokenizer rather than blindly indexing bytes. That is the kind of terminal-app detail you only notice after a diff contains non-ASCII text and your column math starts lying.

The TUI is built on vaxis, not Bubble Tea

Comview uses vaxis, not Bubble Tea or tview. tui/framework.go defines a compact widget interface:

type Widget interface {
	HandleEvent(vaxis.Event) (Command, error)
	Layout(Constraints) Size
	Paint(vaxis.Window)
}

That gives the app three explicit phases: handle terminal events, calculate layout under constraints, and paint into a window. App.Run owns the event loop, render scheduling, terminal overlays, and cleanup hooks.

The frame pipeline is worth noticing. The app has commands such as CommandRedraw, CommandQuit, CommandCopy, and CommandOpenEditor, and the renderer is not forced to repaint on every small state change. For a diff viewer that might be showing thousands of rows, keeping redraw control explicit is a reasonable tradeoff.

State lives in one viewer

tui/app.go defines diffViewer, and it is a big state struct: scroll position, horizontal offset, layout mode, cursor, selection, comments, search query, fuzzy finder, theme, status message, mouse drag state, key chords, help visibility, text-object state, color scheme, and syntax highlighter.

That sounds like a lot because terminal review UIs have a lot of state. The useful part is that the state is in one place, while specific behavior is split into focused files:

  • tui/key_chord.go tracks pending multi-key inputs such as gg or ]c.
  • tui/fuzzy.go implements file and theme matching.
  • tui/syntax.go handles syntax highlighting.
  • tui/themes.go and tui/colors.go keep display choices separate from navigation logic.
  • tui/watch.go handles command reruns and change detection.

The key chord code is tiny: it stores the pending string and timestamp, then clears it after pendingKeyTimeout. That is enough to distinguish a lone keypress from the start of a multi-key command without dragging that logic through the rest of the viewer.

Syntax highlighting is done line-side aware

Comview uses chroma for syntax highlighting. tui/syntax.go matches lexers by filename, caches them, and converts Chroma tokens into vaxis.Segment values.

One subtle bit: HighlightRows keeps old-side and new-side lines separate. Context lines are fed into both sides, deletes into the old side, and additions into the new side. Then each side is highlighted as a block.

That is better than highlighting each row in isolation because lexers often need surrounding context. A multiline string, comment, or raw literal can span rows. Grouping rows before tokenization gives the highlighter a better chance of producing useful colors.

Fuzzy search chooses simple scoring

tui/fuzzy.go does not pull in a fuzzy matching library. It lowercases the candidate and query, walks candidate runes from left to right, and returns a lower-is-better score based on the first match position and gaps between matched characters.

That is enough for file jumping inside a diff. A sophisticated ranking algorithm would be overkill when the candidate set is “files changed in this patch” and results need to update on every keystroke. The implementation is short, allocation-light, and easy to tune if the ranking feels wrong.

Comments are local files

The review/ package is not a GitHub API client. It defines comment data structures and stores review drafts in a local JSON file. The README says comments are saved to .comview/comments.json.

That is a useful constraint. Comview lets you write notes while reading a diff, but it does not have to solve authentication, PR review submission, pagination, or API rate limits. You can still pipe gh pr diff 123 into it when the source is GitHub; the program itself only cares about the diff text and local comment state.

Watch mode is small and effective

tui/watch.go is a good example of keeping a live UI feature simple. It runs a command every 750ms, hashes the output with SHA-256, and posts a vaxis event only when the output changes. Errors are hashed too, so repeated failures do not spam the UI with identical updates.

The command default comes from cmd/comview/main.go: comview watch runs git diff, comview watch --staged appends arguments to git diff, and comview watch -- <command> watches a completely different diff-producing command.

That is a clean CLI design. The program does not need special flags for every Git scenario; it just needs a consistent way to rerun a command and parse the resulting diff.

What to steal

The main thing to copy from Comview is the boundary design. Read a standard format. Parse it once into domain rows. Keep terminal rendering separate from parsing. Use focused helpers for awkward UI details like key chords, fuzzy matching, themes, syntax highlighting, and watch mode.

There is also a useful restraint here. Comview could have grown a GitHub integration, a GitLab integration, and a full review submission workflow. Instead it stays close to the job: make diffs easier to read and annotate in a terminal. That is why the code is still readable.