Terminal UI diff viewer for jujutsu and git
Project description
diff-tree-view
A terminal UI diff viewer for jujutsu and git, inspired by the VCS panel
in VS Code. Run dff inside a repo and get an interactive, keyboard-driven
review experience — tree on the left, rich diff on the right, inline comments
at the bottom that can be copied out as a prompt for Claude.
Status: planning / pre-alpha. This README doubles as the roadmap.
Why
jj diff and git diff are great, but reviewing a multi-file change in a
pager means scrolling through flat output with no structure. Existing TUIs
like lazygit and jjui are fantastic at branch/change operations but treat
diff viewing as secondary. dff is the opposite: it is a dedicated diff
reviewer, with a file tree, collapsible unchanged regions, side-by-side /
unified modes, responsive layout, and a PR-style commenting flow.
Features
Visual design
The aesthetic follows jjui and other
minimalist CLIs (zellij, starship): no Unicode box borders, no "boxed
button" widgets. Regions are separated by three things only —
- Background shade deltas — a panel's boundary is a thin strip of
$surfaceover the transparent terminal background, not a┌─┐box. - Reverse-color "pill" labels — section headers, mode indicators,
and the single colored button (
[ copy ]) are rendered as solid- background padded text (background: $primary; color: $background; padding: 0 1), not bordered widgets. - Inline symbols — jj-style graph chars (
@,◆,○,│,├,└) for the change tree;▸/▾for comment entries and fold markers; middle-dot·as the status-bar separator.
Concretely this means, in TCSS: everything gets border: none; by
default, no Static is wrapped in a Container just to get an outline,
and the entire chrome fits within the transparent-background story —
the terminal wallpaper shows through except under pills and overlays.
Wide layout (terminal width >= 140), full review session:
CHANGES src/app.py M +12 -3 split
@ xmzynnxm tidy logs ────────────────────────────────────────
│ M src/app.py +12 -3 10 def setup(): │ 10 def setup():
│ M src/cli.py +5 -2 11- import os │
│ A tests/test.py +25 12- import sys │ 12+ import pathlib
○ 4f2c6a refactor parser ··· 236 hidden lines ▾ ···················
○ 8b1d9e wip: notes 250 def main(): │ 250 def main():
◆ root() …
COMMENTS 2 [ copy ]
▸ src/app.py R11-12 pathlib would be more consistent?
▸ src/cli.py L40 missing try/except around subprocess
comment › src/app.py R11-12 (RIGHT, added) Enter submit · Esc cancel
› pathlib would be more consistent?_
dff ↑↓ nav · space select · c comment · y copy · m mode · ? more · q quit
Notes on the mockup:
CHANGES,src/app.py ... split,COMMENTS 2,comment, anddffare pills — solid background, padded text, no border.- The only horizontal rule (
────) is a single-lineRule/Statictinted$foreground 20%; it's optional and can be hidden. - Panel separation between tree and diff is a 1-column gap plus a faint
$surface 50%stripe — not a vertical│. @ ◆ ○ │in the tree are the jj graph characters, printed literally. git mode substitutes●forStagedand○forUnstaged.- Middle-dot
·between status-bar hints, styled$foreground 40%.
VCS backends
- jujutsu — show the chain of changes from trunk to
@(default revsettrunk()..@, configurable). Each change is a group in the tree. - git — show
StagedandUnstagedgroups, each with its own file list. - Auto-detect which backend to use based on
.jj/.gitin the repo. - Explicit override via
--backend jj|gitor config.
Change tree (left panel)
- Groups:
- jj: one group per change in the revset range.
- git:
StagedandUnstaged.
- Files nested under a real directory tree (like the VS Code Explorer), with single-child directories optionally collapsed.
- Per-file badges:
M/A/D/Rstatus and+N -Nline stats. - Per-group summary: file count and aggregate
+N -N. - Glob-based ignore list (lock files,
dist/**, etc. shown but de-emphasized). - Keyboard navigation:
j/kor arrow keys,J/Kfor next/prev change.
Diff view (right panel)
Built on top of
textual-diff-view.
- Side-by-side and unified modes, toggleable with
m. - Word wrap for long lines (native to
DiffView, has dedicated wrap/no-wrap compose paths in both split and unified modes); toggled at runtime withw, default fromui.word_wrap. - Character-level diff highlighting inside
replacehunks — changed substrings are tinted more strongly than the line-level background, so you can see exactly which part of a line moved. Native toDiffView. - Syntax highlighting via Textual's
highlightmodule (language is auto-detected from the filename). - Theme-aware — follows the active Textual theme (default:
textual-ansi), so added/removed/gutter colors match your terminal palette. - Transparent background — by default the app uses
textual-ansiand setsScreenand every major container tobackground: transparent, so the terminal's own background (including any translucent terminal effect) shows through. Only overlays (completion popup, help, confirm dialogs) keep a solid$surfacebackground. Can be turned off withui.transparent = false. - Collapsible unchanged regions (VS Code-style): contiguous unchanged
lines are folded into a
N hidden linesmarker; click or pressEnterto expand. Context size and "always expand if small" threshold are configurable. - Synchronized scrolling in split mode.
- Line numbers from both
beforeandaftersides.
Live auto-refresh (file watcher)
Both the change tree and the currently-open diff are kept live. You never
need to quit and relaunch dff after editing, staging, or running jj/git
commands in another terminal.
- A background watcher (based on
watchfiles, Rust-backed, asyncio-native) observes:- The working tree (respecting
.gitignore/.jjignore) — catches edits to tracked files. .git/index,.git/HEAD,.git/refs/**— catchesgit add, commits, branch switches..jj/internal state — catchesjj squash,jj new,jj abandon, working-copy snapshot changes.
- The working tree (respecting
- Events are debounced (default 150 ms) so a burst of writes only triggers one reload.
- On event: re-run the backend's
list_changes()+ refresh stats; if the currently selected file changed, re-render its diff in-place. Scroll position and the set of expanded fold regions are preserved across refreshes where possible. - Comments are not cleared on refresh. Line numbers in stored comments
are re-anchored via the new hunk map; if a commented range no longer
exists after the refresh, it's marked
stale(dimmed, still copyable). - Manual
rstill works for forcing a reload. - Disable with
[vcs] watch = false, or tune with[vcs.watch] debounce_msand[vcs.watch] extra_ignore_globs.
Responsive layout
Like a responsive web page, dff reflows based on terminal width:
| Width | Layout |
|---|---|
>= 140 |
Tree + split diff + comment bar |
100 – 140 |
Tree + unified diff + comment bar |
< 100 |
Single panel, Tab switches tree / diff |
Breakpoints are configurable.
Line selection & commenting
PR-review workflow without leaving the terminal.
LEFT vs RIGHT (which side you're commenting on)
Every comment is anchored to a side, matching how GitHub / GitLab / Gerrit / Sublime Merge model review comments:
- LEFT = the removed / before version (what the
-lines show). - RIGHT = the added / after version (what the
+lines show). - A comment spanning both sides of the same hunk is labelled
hunk-level.
How side is determined:
- Split mode: the focused column.
h/l(orTab) switches column focus; clicking a column focuses it. - Unified mode: by the line prefix —
-→ LEFT,+→ RIGHT. For a context line (), default side is RIGHT (the usual "comment on the new code" intent); press[to force LEFT,]to force RIGHT before opening the comment.
A selection must belong to a single side. If you drag across sides in split
mode, dff asks whether to split it into two comments or abort
(comment.cross_side = ask | split | reject).
User flow
- Select — mouse click-drag, or
Spaceto start/extend a line selection (Shift+↑/↓to grow). The gutter shows a live tag likeL11-12 (LEFT, removed)orR8-11 (RIGHT, added). - Press
c— the bottom comment bar focuses, prefilled with the anchor:src/app.py R8-11 (RIGHT, added). - Type —
Entersubmits (adds to the in-sessionCommentStore),Shift+EnterorCtrl+Jinserts a newline,Esccancels. - Manage — focus the comment list (
g c), thenEnterjumps to the anchor (opens the file, expands folds, scrolls to the lines),eedits,ddeletes. - Export —
y(or click[copy]) serializes all comments as a markdown prompt and puts it on the clipboard. - Live re-anchoring — when the watcher refreshes, each comment is
re-anchored via the new hunk map using
(side, line_range, content_hash). Shifts are followed automatically; if the commented content no longer exists the comment is markedstale(dimmed).
Exported prompt format
Designed so Claude understands the LEFT / RIGHT distinction without
ambiguity — the side label is spelled out, and the quoted lines are a
real fenced diff block:
# Code review comments for z9a7 "tidy logs"
> Diff sides: **LEFT** = removed (before), **RIGHT** = added (after).
## src/app.py
### L8-10 (LEFT, removed)
```diff
- import os
- import sys
- from pathlib import Path
Why not drop the whole block?
R9-11 (RIGHT, added)
+ import pathlib
+ from typing import Annotated
Does the typing import belong here?
L9-10 ↔ R8-11 (hunk-level)
Overall refactor is fine but please split into two commits.
Whether code snippets are embedded (the fenced `diff` blocks) is
controlled by `comment.include_code_snippet`. The whole template is
overridable via `comment.templates.custom.path` (Jinja).
### Configuration
TOML, loaded in this order (later overrides earlier):
1. Built-in defaults
2. `~/.config/dff/config.toml`
3. `./.dff.toml` (project-local)
4. Environment variables (`DFF_UI_THEME=...`)
5. CLI flags
Configurable areas:
- **`[ui]`** — theme, default diff mode, word wrap, line numbers, syntax
highlighting on/off, transparent background on/off, responsive breakpoints.
- **`[fold]`** — enabled, context lines, "always expand if small" threshold.
- **`[tree]`** — group by change vs directory, show stats, collapse single-
child dirs, ignore globs.
- **`[vcs.jj]`** — default revset, `--ignore-working-copy`, whether `@` is
split out as its own row.
- **`[vcs.git]`** — show staged / unstaged, whether to merge them into one
"Working" group.
- **`[performance]`** — max file lines before falling back to plain text,
max files per change, parallel subprocess count.
- **`[vcs.watch]`** — enabled, debounce interval (ms), extra ignore globs,
whether `.git/` and `.jj/` internal state are watched.
- **`[comment]`** — clipboard vs file export, include code snippet in
prompt, custom Jinja prompt template, `cross_side` behavior
(`ask | split | reject`), default context-line side
(`context_side = right | left`).
- **`[keys]`** — every key binding is rebindable.
- **`[integrations]`** — `$EDITOR` for `e` key.
A fully-commented `config.example.toml` ships with the project.
### Keybindings (defaults)
| Key | Action |
|-----------|---------------------------------------|
| `j` / `k` | Next / previous item in tree |
| `J` / `K` | Next / previous change group |
| `Enter` | Expand tree node / fold region |
| `Space` | Toggle line selection (diff) |
| `[` / `]` | Force comment side to LEFT / RIGHT (context lines) |
| `h` / `l` | Switch focused column (split mode) |
| `c` | Start a comment on current selection |
| `g c` | Focus the comment list |
| `Esc` | Cancel comment input / clear selection|
| `y` | Copy all comments as prompt |
| `m` | Toggle split / unified |
| `Tab` | (narrow) switch tree ↔ diff panel |
| `r` | Force refresh (normally automatic) |
| `w` | Toggle word wrap |
| `e` | Open current file in `$EDITOR` |
| `?` | Help overlay |
| `q` | Quit |
---
## Installation
Requires Python 3.14+.
```bash
uv tool install diff-tree-view # once published
# or for development:
uv sync
uv run dff
Runtime deps: textual, textual-diff-view, watchfiles, typer.
pyperclip is declared but not wired up yet (reserved for the v0.2
comment export flow).
Usage
dff # auto-detect jj or git, show default revset
dff --rev 'trunk()..@' # explicit jj revset
dff --backend git # force git
dff --rev HEAD~3..HEAD # git rev range
dff --staged # git staged only
dff --mode unified # override default diff mode
Inside the TUI, press ? for the full keymap.
Architecture
src/diff_tree_view/
cli.py Typer CLI; --backend / --rev / --version
app.py Textual App; composes tree + diff + status bar
config.py UISettings dataclass (TOML loader planned)
theme.py TreeThemeTokens + built-in DARK / LIGHT palettes
terminal.py OSC-11 background probe → auto dark / light
vcs/
base.py Protocol: Backend, BackendError
detect.py Picks jj vs git; walks up to repo root
jj.py subprocess: jj log / diff --summary / file show
git.py subprocess: git diff --name-status / show
watcher.py watchfiles-based async iterator;
debounced events drive App._refresh_changes()
widgets/
change_tree.py Tree widget with VS Code-style grouping
diff_panel.py Header + TransparentDiffView (subclass of
textual-diff-view that blanks the split-mode
hatch and pulls colors from TreeThemeTokens)
status_bar.py Single-line hint bar (• separators)
app.tcss Global stylesheet. Rules:
* { scrollbar-background: ansi_default } so the
terminal backdrop shows through scrollbar
tracks; App.-transparent / App.-opaque toggle
the Screen + panel backgrounds; all tree cursor
/ guide / highlight classes are flattened to
`transparent` + `text-style: none`.
models/
change.py Change, FileChange, FileSides, HunkStats
Planned but not yet implemented: layout.py (responsive breakpoints),
widgets/collapsible_diff.py, widgets/line_selection.py,
widgets/comment_bar.py, models/comment.py, prompt.py.
Transparent background — how it works
Three pieces, combined:
- Theme —
App.theme = "textual-ansi"inapp.py. ANSI theme keeps palette decisions on the terminal side instead of forcing a light/dark surface. - Global TCSS —
app.tcsstoggles betweenApp.-transparentandApp.-opaquebased onUISettings.transparent_background. In transparent mode,Screen,#app-shell,#panes,#diff-body,ChangeTree,DiffPanel,DiffHeader, and#status-barare all set tobackground: transparent; the diff surface explicitly usesansi_defaultso Rich emits[49minstead of flatteningrgba(0,0,0,0)to solid black. All scrollbar tracks usescrollbar-background: ansi_defaultfor the same reason. - Tree cursor & guides —
.tree--cursor,.tree--guides-*, and the hover/highlight classes are flattened tobackground: transparentwithtext-style: none, so Textual's default theme-colored hover / selection rectangles never paint over the terminal wallpaper.
TransparentDiffView further substitutes the diagonal-hatch "missing
line" marker with blank space and pulls diff-add / diff-remove background
shades (diff_add_bg / diff_remove_bg / diff_add_char_bg /
diff_remove_char_bg) from the active TreeThemeTokens. Those tokens
ship in two built-in palettes (DARK / LIGHT) and are picked
automatically via terminal.detect_tree_theme_name() (OSC-11 query).
VCS command cheatsheet
jj
- Changes in revset:
jj log -r '<revset>' --no-graph --ignore-working-copy -T '<template>' - Files in a change with status:
jj diff -r <id> --summary --ignore-working-copy - File content (before / after):
jj file show -r <id>- <path>/jj file show -r <id> <path>
git
- Staged file list:
git diff --cached --name-status - Unstaged file list:
git diff --name-status - Content:
HEAD:<path>(before staged) /:<path>(staged index) / worktree file (after unstaged)
Roadmap
v0.1 — MVP
- Project scaffold (uv, Textual app skeleton, CLI)
- VCS backend abstraction + auto-detect
- jj backend (read-only)
- git backend (read-only)
- Change tree widget with stats and M/A/D/R
- Integrate
textual-diff-view(split + unified, wrap toggle) - Responsive layout (split / unified / tabs)
- File watcher (
watchfiles) auto-refresh for tree + diff - Minimal config:
[ui],[vcs.jj.revset],[vcs.watch],[keys],[comment.clipboard]
v0.2 — Review workflow
- Collapsible unchanged regions (extend
DiffView) - Line selection (mouse +
Space) - Comment bar with in-session store
- Copy-as-prompt (markdown, clipboard)
- Custom Jinja prompt template
-
[fold],[performance]config sections
v0.3 — Polish
- Open-in-editor (
e) - Help overlay (
?) - Ignore-globs with de-emphasized rendering
- Large-file fallback (plain text, no highlighting)
- Refresh (
r) without reloading the app
Later
- Write support for comments (persist to
.dff/reviews/*.md) - Watch mode (auto-refresh on file change)
- Jump to parent change / child change in jj
- Inline comment rendering next to the diff (not just at the bottom)
Non-goals
- Not a VCS operations tool. No commit, squash, rebase, push. Use
jj,git,jjui, orlazygitfor that.dffis read-only reviewing. - Not a merge conflict resolver.
- Not a PR client.
dffdoes not know about GitHub / GitLab. It only produces prompts / markdown you can paste elsewhere.
Development
uv sync
uv run dff # run against current repo
uv run pytest # tests
uv run ruff check .
uv run ruff format .
Credits
textual— the TUI framework.textual-diff-view— the diff rendering widget.jjui— reference for how to drivejjfrom a TUI.- VS Code — the UX reference for the file tree and inline fold markers.
License
TBD.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file diff_tree_view-0.0.1.tar.gz.
File metadata
- Download URL: diff_tree_view-0.0.1.tar.gz
- Upload date:
- Size: 35.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.21 {"installer":{"name":"uv","version":"0.9.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff67af33506d8f46aac22cdf161a5fd33f57765d1dcdcb5f14708d328183102c
|
|
| MD5 |
83cfe59b2777a0a7a2eb3f6d1af46856
|
|
| BLAKE2b-256 |
222fff7cd80a9082b884b13a4894d940eddb38d9f632f0e21e5533f1f8d94544
|
File details
Details for the file diff_tree_view-0.0.1-py3-none-any.whl.
File metadata
- Download URL: diff_tree_view-0.0.1-py3-none-any.whl
- Upload date:
- Size: 43.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.21 {"installer":{"name":"uv","version":"0.9.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8817f74d018648587126c46546539590cc5fc3e69f982f792db7e39bcc225076
|
|
| MD5 |
96d502b344e907280aa0b8c26a5e424b
|
|
| BLAKE2b-256 |
72d954b717c587607efd141c7bcf635d0d34d71053b40232e3bfced7176b07c7
|