lgtm - Read What Your Agent Wrote#
██╗ ██████╗ ████████╗███╗ ███╗
██║ ██╔════╝ ╚══██╔══╝████╗ ████║
██║ ██║ ███╗ ██║ ██╔████╔██║ 👍
██║ ██║ ██║ ██║ ██║╚██╔╝██║
███████╗╚██████╔╝ ██║ ██║ ╚═╝ ██║
╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝lgtm is a terminal diff reviewer for agentic coding. It runs in a pane beside your agent, shows what changed as it changes, and lets you point at exact lines when you reply.
You keep your editor. hjkl, w b e, f t F T, / and n, V to select, zz to centre - the motions already in your fingers, now pointed at the agent's work.

Status: pre-alpha, and used daily by its author. It began as a Go tool for reading diffs in a terminal, and is now a rewrite in Zig rebuilt around a coding agent rather than a person: the diff re-renders as the agent writes, references go straight to its input, and comments follow the code when it rewrites the file underneath them.
Why#
Reviewing an agent is not reviewing a person: more code, arriving faster, with no prior about where the risk is.
Without this, the loop goes - the agent edits six files, you scroll git diff in a pager, you spot something at src/auth.zig:47, and you retype that path into the chat. lgtm removes the retyping.
- Live - polls every 500 ms; nothing to re-run, nothing to refresh
- Vim motions - the review is a buffer, not a pager
- One keystroke to the agent -
Entersends#3 src/auth.zig:47into your agent's input box, never submitted - Comments that survive rewrites - they follow the code when the agent moves it, and say so when they cannot be placed
- Snapshots - every turn the agent takes, in git's own object store, including the tree before it started
- Weakened-test detection - a deleted test, an added
skip, a body that stopped asserting - Plain files - comments as jsonl, reviews as markdown, all under
.lgtm/
Numbers#
Measured on macOS arm64, ReleaseFast, in the lgtm repository:
| Measurement | Result |
|---|---|
| Binary | 766 KB, one dependency (libSystem) |
| Frame | 0.30 ms at 80x26 |
| Re-diff | 40 ms: one git diff plus the parse |
| Re-anchoring comments | 100% across the fixture set (24/24), 1.8 ms per 50 |
Where to go next#
| Page | What's there |
|---|---|
| Installation | Every install path, and what else you need |
| Getting Started | First run, the loop in five minutes |
| Reading the Diff | Motions, search, hunks, files, views |
| Talking to the Agent | References, the compose box, presets |
| Comments | Collect a dozen remarks, submit once |
| The Mark | What arrived since you last read, and weakened tests |
| Snapshots | Turns, restore, and getting your work back |
| Keyboard Shortcuts | Every key, in one place |
| Configuration | Every setting in config.toml |
| Theming | Seven bundled themes and every colour slot |
| Keybindings | Rebind anything; every command name |
| Terminals | Which terminals lgtm can type into |
| Troubleshooting | When something doesn't work |
Installation#
macOS#
From the tap:
brew install kunkka19xx/tap/lgtmLinux and macOS (install script)#
Any Linux distribution, and macOS; arm64 and x86_64. The Linux build is statically linked, so there is no libc to match and no distribution to be right about - this is the path on Debian, Ubuntu, Fedora, Alpine, openSUSE and anything else without an entry below.
curl -fsSL https://raw.githubusercontent.com/kunkka19xx/lgtm/main/scripts/install.sh | shThe script needs no sudo - one binary goes in ~/.local/bin - and it will not install over a copy something else manages. It verifies every download against the SHA256SUMS published in the same release and stops on a mismatch.
scripts/install.sh --dry-run # say what it would do, do nothing
scripts/install.sh --version v0.1.0
scripts/install.sh --dir /opt/bin
scripts/install.sh --uninstallArch Linux#
From the AUR, with whichever helper you use, or none:
paru -S lgtm-bin # or: yay -S lgtm-bin
git clone https://aur.archlinux.org/lgtm-bin.git && cd lgtm-bin && makepkg -silgtm-bin is the release binary and needs no toolchain. lgtm-git builds main instead and needs zig, which is in extra for both architectures.
Nix#
The flake exposes the binary as a package, not just a dev shell:
nix run github:kunkka19xx/lgtm # run it once, install nothing
nix profile add github:kunkka19xx/lgtm # keep it on PATHnix run builds into the store and runs it - nothing joins your profile or your PATH, and the next nix-collect-garbage reclaims the build, so trying it costs nothing. nix profile add is the one that persists; nix profile remove lgtm undoes it. Both need flakes enabled. (nix profile install is the old spelling of add, and warns.)
From source#
Needs Zig, the version pinned in .zigversion:
make local # build and install to ~/.local/bin
make clean-local # remove it, restoring whatever it displacedmake local will not install over a symlink and will not delete a binary it did not write, so a copy from a package manager is safe from it.
Windows#
There is no Windows build. io/input.zig and io/tty.zig are POSIX throughout, so that needs a port rather than a manifest.
What else you need#
git is required - lgtm reads git diff for a living.
tmux is optional but is what lets lgtm type into your agent's input box; without it, references go to the clipboard over OSC 52, which works over SSH. See Terminals for the full support matrix.
Checking it works#
lgtm -v # version, author, repository
lgtm # in any git repositoryIn a directory that is not a repository, lgtm starts, says so, and lets you quit or talk to the agent. It never fails to start.
Getting Started#
Run lgtm in a pane next to your agent. It polls every 500 ms; there is nothing to re-run and nothing to refresh.
The loop in five steps#
| Step | Key | What happens |
|---|---|---|
| Read | j k, ]h, / | The diff against HEAD - staged, unstaged and untracked |
| Point | Enter | A reference like #3 src/auth.zig:47 lands in the agent's input box |
| Comment | Space c | Notes collect on the lines they belong to |
| Submit | Ctrl-s | Writes .lgtm/review-3.md, tells the agent, and marks what you read |
| Come back | ]m | The lines that arrived since the mark carry a bar in the gutter |
Configuring it#
There is nothing to configure to start: lgtm writes no config file and runs on compiled-in defaults. When you want to change one:
lgtm --init # ~/.config/lgtm/config.toml
lgtm --init --config .lgtm/config.toml # this repository's, meant to be committedEvery line in the file it writes is commented out and shows the default, so it changes nothing until you uncomment something - and a default improved in a later release still reaches you. --init never overwrites a file that is already there. Configuration is the full reference.
Reviewing something other than the working tree#
| Command | What it shows |
|---|---|
lgtm | HEAD against the working tree - the default, and what the tool is about |
lgtm --base main | Your whole branch, including what you have not committed. Live: the tree is still the right-hand side, so it still updates as the agent writes |
lgtm --base main --target HEAD | Committed work only, as two trees. Static - nothing can move, so the watcher, the snapshots and the mark are all off |
The badge says which: main or main..HEAD in the accent instead of NORMAL, because a diff against a branch looks exactly like a diff against HEAD and reading one as the other is the mistake worth preventing.
Where things live#
Everything durable is a plain file in .lgtm/, which lgtm keeps out of your review with its own .gitignore:
| File | What it holds |
|---|---|
.lgtm/comments.jsonl | Your comments |
.lgtm/review-N.md | What Ctrl-s wrote |
.lgtm/state.json | The session, the turn count, where you read to |
.lgtm/config.toml | This repository's settings, if you commit one |
Kill lgtm and restart it; you lose scroll position and nothing else.
Reading the Diff#
What you are looking at is the diff against HEAD - staged and unstaged, with untracked files included. It re-renders as the agent writes.
Motions#
The motions are vim's, because they are already in your fingers:
j k h l, w b e, W B E, 0 ^ $, f t F T with ; and ,, gg and G, Ctrl-d and Ctrl-u, zz.
} and { move to the next and previous break - a blank line, or a hunk edge.
Jumping around#
]h and [h walk hunks across the whole review; ]f and [f walk files. Both wrap, and say so when they do.
Every ]x has a Space nx spelling and every [x a Space px, for anyone whose terminal makes brackets awkward.
`;` repeats whichever jump you used last, and `,` goes back. After ]h they walk hunks, after ]w weakened tests, after n search matches. After f( they are vim's, exactly. That is the whole rule: ; is "that again", so the keystroke you spend most costs one key instead of two, and you never have to remember which family you are in. A second , keeps going back rather than turning round, the way vim's does.
Search#
/ searches the whole review, not just the file on screen. Matches highlight as you type. n and N step; Esc or :noh clears the highlight and keeps the pattern.
* searches for the identifier under the cursor, and # does it backwards. This is the review's most common question, now that a name has changed: where else does it appear? It costs one key. Matched whole, so * on id walks the places id is used rather than stopping in every width, valid and ident between them. / is still there when you mean a fragment.
Views#
Tab is zen: it hides the chrome. zw toggles soft wrap. The pipe key, or -, switches between side by side and the flow view; in the split view H and L focus the old or the new column.
The layout is responsive by default: side by side when the pane is wide enough, flow when it is not.
Side by side puts the old file on the left and the new on the right, with a filler block where one side has no line - the shape of what was added or taken away, rather than a hole in the middle of it:

The flow view takes the whole pane for one column, and spends the four-column gutter a terminal without colour can still read:

Below 60 columns there is no side by side at all, whatever the setting says - that is a floor rather than a threshold. Shrinking a pane past it falls back to flow and widening brings the split straight back, because the layout you asked for is suspended rather than forgotten. See [diff] under Configuration.
Reading anything, not just the diff#
Space f lists the changed files; Space F lists every file git knows about. An unchanged file opens whole, outside the review - still readable, still commentable. A file too large to render inline opens with zo and folds again with zc.

zi shows the files that [review] ignore hides. Hidden files are counted on the status line - nothing is ever hidden silently.
Space e opens the line under the cursor in $EDITOR. Ctrl-r re-diffs now.
Talking to the Agent#
This is the part that removes the retyping.
Pointing at a line#
Enter opens the compose box holding a reference to the line under the cursor:
#3 src/auth.zig:47Type what you want to say and Enter sends it - inserted into your agent's input box, never submitted, so you decide when to press return.
| Key | What it sends |
|---|---|
V then Enter | A line range: :47-52 |
v then Enter | The selected words themselves |
Space a | The box, with your [presets] list already open |
Space y | The reference to the clipboard instead of the agent |
Space Y | The reference and the lines |
y Y | Yanks the selected text, the way y does in vim |
If lgtm cannot type into your terminal, Enter degrades to the clipboard rather than failing - still a paste away. See Terminals.
Inside the compose box#
Esc leaves insert for normal mode, where the same vim motions work and o opens a line; a second Esc leaves the box. Nothing you type is deleted by either.
| Key | What it does |
|---|---|
Enter | Send |
Ctrl-i | Insert a preset at the caret |
@ | Insert a file path |
Ctrl-j | Line break |
Ctrl-s | Save this as a comment and send it at once |
In normal mode the box offers the review's motions plus i a I A o O x D C dd cc d{motion} c{motion} u.
Presets#
Presets are the questions you ask most, kept as config so you type them once:
[presets]
why = "why this approach?"
perf = "is this hot path allocating?"
test = "add a test covering this"
revert = "revert this, keep the rest"A preset is inserted at the caret and deletes nothing, so you can drop one into a sentence you are half way through.
Rewriting what gets sent#
Every sentence lgtm sends your agent is a template you can override - see [templates] under Configuration:
[templates]
ref_single = "look at {path} line {line}"
ask_test = "{ref} - a table test, not a unit test"
submit_review = "please review {path} - {count} note{s} waiting"The Mark, and Weakened Tests#
Coming back to what's new#
Ctrl-s marks the working tree as read, and m does it by hand. When the agent revises, the lines that arrived since carry a bar in the gutter:
1 fn login(u: []const u8) bool {
− 2 return check(u);
+ 2 const t = trace(u); ← was there when you marked
+┃ 3 if (!t) return false; ← arrived since
+┃ 4 return check(u) and audit(u);]m and [m walk those changes, M or :nomark drops the mark. The mark survives quitting lgtm: come back tomorrow and it still means the same thing.
The mark never hides anything. You are always looking at the whole diff against HEAD; the bars are an annotation on top of it.
Watching for a weakened test#
The failure with the worst consequences, and the one hardest to catch by reading: a test deleted because it failed, a skip added, a body that stopped asserting. It turns a red build green while looking like ordinary cleanup.
lgtm counts what a change did to its tests on every re-diff and says so on the status line:
1 test removed, 1 skip added, 1 fewer assertion]w and [w walk to them - to the removed declaration or the added skip, so you land on the thing rather than near it.
Detected by content, not by path, so it works for languages that keep tests in the source file. Two of the three signals are near-certain and lead; "fewer assertions" follows behind, because a refactor that merges two checks into one looks the same.
Zig, Go, Python, JavaScript, TypeScript, Rust and Swift are described; anything else stays silent rather than guessing.
Snapshots & Turns#
lgtm snapshots the working tree whenever the agent stops writing, into git's own object store under refs/lgtm/**. The first snapshot is taken before the agent has written anything, which is the one nothing else could recover: uncommitted work is invisible to git until you lose it.
Walking the turns#
| Key | What it does |
|---|---|
]t [t | Walk the turns, ending at the working tree |
Space lt | List them: what each touched, when, how big |
R | Restore this file from the turn on screen |
u | Undo the last restore |
The list stays a screenful however long the session runs. The newest few turns are always drawn, and so are the mark, 0 original and the turn you are looking at; everything between them collapses into ⋮ 13 turns. Enter opens a fold, and typing anything opens all of them - a filter that skipped folded rows would be a filter that lies.
Four turns in a row on one file collapse too, as ⣿ 1-4 app.zig ×4: that is one piece of work that took four tries, not four things to read. A run never folds across a row that has something of its own to say.
Two markers that earn their place#
| Marker | Meaning |
|---|---|
↺ 4 | The agent undid its own work - this turn put a file back to exactly what turn 4 left. Round-tripping is what agents do when they are stuck, and it is nearly invisible in a diff, because a diff only shows the endpoints |
↩ | This turn touched a file you commented on - the agent answering you, as against doing something else |
A turn is read-only#
Comments, m and Ctrl-s refuse there and say why. The badge reads TURN 2 or BASELINE so you always know you are in the past, and grows a • when the working tree changes while you are back there - nothing updates under you, but you are told the world moved.
Restoring a file#
R takes a snapshot before it writes, asks before it writes, restores one file and no more, and then names the turn that undoes it. It is the only thing in `lgtm` that writes to your files.
u undoes that restore - one step, this session only. It refuses if anything has changed the file since, because then putting the old bytes back would not be undoing your action, it would be discarding whatever came after it. Going the other way is the move the notice names: [t to the turn it made, then R. That is the real mechanism; u is its shortcut.
They are ordinary git objects#
Without lgtm installed:
git show refs/lgtm/<session>/0:src/auth.zig # the pre-agent version
git log --graph refs/lgtm/<session>/7 # the sessionThey are invisible to git branch and git status. git log --all walks every ref, so they do appear there. Deleting the refs is all it takes to be rid of them.
Pruning#
[snapshot]
keep = 36Turns of the current session kept before the oldest are pruned; minimum 4. Pruning deletes refs; the objects go when git gc next runs.
Two turns are pinned whatever `keep` says: the baseline, 0 original, which is the tree as it was before the agent ran and the one snapshot nothing else can reconstruct; and the turn the mark sits on, which is what ✓, "since the mark" and ]m all point at. So lowering keep costs the middle of a long session and neither of its ends. Other sessions are never pruned - they are somebody's afternoon, and git shares the objects anyway.
Snapshots carry every changed file git reports, including ones [review] ignore keeps off the screen. That is deliberate: a file hidden from the review is still a file an agent can destroy, and the two kinds of ignoring are different questions. .gitignore is still respected.
Keyboard Shortcuts#
? shows this list generated from your bindings, so a remapped keymap documents itself. What follows is the defaults.

Moving#
| Key | What it does |
|---|---|
j k | Down and up a line |
h l | Left and right a character |
w b e | Next, previous, end of word |
W B E | The same over WORDs - only blanks separate |
0 ^ $ | First, first non-blank, last column |
f t F T | To or before a character on this line |
; , | Repeat the last jump, either way |
Ctrl-d Ctrl-u | Half a page |
gg G | First and last line |
} { | Next and previous break: a blank line, or a hunk edge |
zz | Centre the cursor line |
Jumping#
| Key | What it does |
|---|---|
]h [h | Next and previous hunk (wraps) |
]f [f | Next and previous file (wraps) |
]c [c | Next and previous comment |
]m [m | Next and previous change since the mark |
]t [t | Next and previous turn |
]w [w | Next and previous weakened test |
/ n N | Search the review, then step |
* # | Search for the word under the cursor, forwards or back |
Space f | The changed files |
Space F | Every file in the project |
Every ]x has a Space nx spelling and every [x a Space px.
Talking to the agent#
| Key | What it does |
|---|---|
Enter | Compose a message about this line |
Space a | Compose, with the question list open |
Space y Space Y | Copy the reference, or the reference and the lines |
y Y | Yank the selection, or whole lines |
v V | Visual select, characters or lines |
Comments#
| Key | What it does |
|---|---|
Space c | Write one here |
Space vc | Open the nearest to read or edit |
Space lc | List every comment |
Space sc | Send this one on its own |
Space dc | Delete the one here |
Ctrl-s | Submit the review, and mark what you read |
Turns and the mark#
| Key | What it does |
|---|---|
m | Mark: everything after this is new |
M | Drop the mark |
Space lt | List the turns. Typing a number finds that turn; typing a word searches the row |
R | Restore this file from the turn on screen |
u | Undo the last restore |
]w [w | Walk the weakened tests |
View#
| Key | What it does |
|---|---|
Tab | Zen: hide the chrome |
zw | Soft wrap long lines |
Pipe key or - | Side by side, or back to the flow view |
H L | Side by side: focus the old or the new column |
zi | Show the files [review] ignore hides |
zo zc | Open a file too large to render inline, or fold it |
Ctrl-r | Re-diff now |
Space e | Open this line in $EDITOR |
? | Every key, from your bindings |
: | Run any command by name; Tab completes |
:q | Quit |
The command line#
: runs any command by the name [keys] binds it by, so every command in the Keybindings list is typeable whether or not it has a key:
:next_file :turn_list :toggle_wrap
:q :qa :noh :nomarkTab completes. The first press extends to whatever every candidate shares, so :n becomes :next_ without choosing between them; the next presses cycle, and Shift-Tab goes back. The candidates appear on the line above, which is the rule the prompt was covering anyway, so nothing on screen moves to make room:
next_hunk next_file next_comment next_turn next_fresh next_risk
:next_If nothing starts with what you typed it falls back to a loose match, so :nf still reaches next_file.
The short spellings are vim's and mean the same as the long ones. A name that does not exist suggests the nearest one that does, and a command that only lives inside the compose box or a list says so rather than running somewhere it has no meaning. Neither the suggestion nor Tab will ever offer a command : would then refuse.
? shows the keys and : runs the names. Both read the same table, so neither can drift from what the tool actually does.
In a list#
J and K move, H and L page, Enter opens, Esc closes, and typing filters. Tab and Shift-Tab, the arrow keys, and Ctrl-n / Ctrl-p all move too.
In the compose box#
Esc leaves insert then leaves the box, Enter sends, Ctrl-i inserts a preset, @ inserts a file path, Ctrl-j is a line break, Ctrl-s saves a comment and sends it at once. In normal mode: the review's motions plus i a I A o O x D C dd cc d{motion} c{motion} u.
Configuration#
Two files, merged key by key:
~/.config/lgtm/config.toml yours, everywhere
.lgtm/config.toml this repository'slgtm --init writes the first of these, and lgtm --init --config .lgtm/config.toml the second. Every line in what it writes is commented out and shows the default, so the file changes nothing until you uncomment something; it never overwrites a file that already exists.
The repository's wins where they disagree. .lgtm/ is ignored by the .gitignore lgtm writes there, except config.toml - so a project can commit its own settings without committing anyone's session state.
A bad setting never stops `lgtm` starting. It is reported on the status line with the file, the line and the key, and only that one key falls back to its default. This is a rule, not an accident: a review tool that refuses to open because of a typo is a review tool you stop running.
nav#
| Key | Default | What it does |
|---|---|---|
hunk_crosses_files | true | ]h carries into the next file at the end of one. False keeps hunk motions inside the current file, wrapping there |
scrolloff | 3 | Rows kept between the cursor and the edge. Clamped to a third of the body, so a large value on a short pane degrades instead of pinning the cursor to the middle |
mark_on_submit | true | Ctrl-s takes the mark as well as sending the review. False for a mark you want to span several rounds |
[nav]
hunk_crosses_files = true
scrolloff = 3
mark_on_submit = truediff#
| Key | Default | What it does |
|---|---|---|
layout | "auto" | "auto", "flow" or "split". flow is the one-column diff; split is side by side. auto is responsive: side by side when the pane is wide enough, flow when it is not. Switching views at runtime beats auto. "unified" is accepted as a spelling of "flow" |
highlight | "line" | "line" washes the whole changed row; "gutter" keeps the colour in the sign and the line number and leaves the code to the syntax highlighting |
split_min_width | 100 | Below this many columns, auto reads flow. Each side needs a line number, a sign, a gutter and about forty columns of code, with a divider between them; under that, side by side wraps so hard it shows less than the flow view. Minimum 60 |
[diff]
layout = "auto"
highlight = "line"
split_min_width = 100The wash colours are mixed from the theme rather than written per theme: the hue a fifth of the way over the theme's own background, so a palette that publishes a green and a background already says what its diff green is. The default terminal palette is built from 256-colour indexes, which cannot be mixed with anything, so it takes three from the fixed colour cube instead - stronger than a mix, and deliberately so. Set add_line, del_line or filler under [theme] to override any of them. filler is the side of a split row that has no line on it: the shape of what was added or taken away, rather than a hole in the middle of it.
[ui] wrap governs both views. A split row takes as many screen rows as its taller column needs, so the two sides stay aligned and neither is cut off at the divider; zw turns it off in the split view the same way it does in the flow view. Continuation rows follow the line's own indentation, capped at a third of the column so a deeply nested line still has most of it to wrap into; a review note wraps flush, because indentation in prose is whatever the writer happened to type. A file with no hunks - one opened whole with Space F - stays flow, because both of its sides would be the same text.
Below 60 columns there is no side by side at all, whatever layout says. That is a floor rather than a threshold: split_min_width is where auto stops choosing two columns, and 60 is where they stop being possible - a gutter of about five and twenty-four columns of code a side, plus the divider.
The two views spend different gutters. The flow view has the pane to itself and spends four columns: the sign, the column the mark's bar sits in, the number, and two after it - the first where a comment's dot goes, the second air the code reads better for. The split view has halved itself already and spends one: the number, and the single column between it and the code. That column is the separator, the comment's dot and the mark's bar at once, whichever the line has earned - and a comment wins it, because a comment is something you put there on purpose while ]m will walk you to the mark anyway. On an even-width pane the odd column goes to the new file, which is the side being reviewed.
ui#
| Key | Default | What it does |
|---|---|---|
wrap | true | Soft wrap long lines. zw toggles it for the session. A wrapped code line's continuation rows start under the line's own indentation, so a run-on reads as one statement rather than as the start of a new one |
icons | "unicode" | "nerd", "unicode" or "ascii". Only nerd has filetype icons; ascii exists for a terminal that would draw the rest as tofu |
comments | "marker" | "marker" is the gutter dot alone; "inline" folds the comment text under the line it belongs to |
compose | "bottom" | "bottom", "top" or "centre" - where the compose box opens |
scroll_ms | 250 | How long a jump takes to travel, up to 1000. 0 turns the animation off |
cursor_ms | 80 | The same for the cursor |
[ui]
icons = "nerd"
comments = "inline"
compose = "bottom"
scroll_ms = 0 # instantreview#
ignore keeps paths out of the review. These are git pathspecs, passed to git as :(exclude), so the glob semantics are .gitignore's exactly - there is no matcher here to get subtly wrong. git never parses the hunks either, so a 900-line lockfile costs nothing.
What this is for is the file .gitignore cannot help with: the generated ones that are tracked on purpose.
[review]
ignore = ["package-lock.json", "**/*.pb.go", "dist/**"]Hidden files are counted on the status line - nothing is ever hidden silently - and zi reveals them.
presets#
Questions for the compose box's Ctrl-i list, and for Space a. Any names, any number.
[presets]
why = "why this approach?"
perf = "is this hot path allocating?"
test = "add a test covering this"
revert = "revert this, keep the rest"A preset is inserted at the caret and deletes nothing, so you can drop one into a sentence you are half way through.
snapshot#
[snapshot]
keep = 36Turns of the current session kept before the oldest are pruned; minimum 4. See Snapshots & Turns for what pruning does and does not touch.
templates#
Every sentence lgtm sends your agent, as data. Override the ones you want; the rest keep their defaults.
[templates]
submit_review = "please review {path} - {count} note{s} waiting"
ref_single = "look at {path} line {line}"
ask_test = "{ref} - a table test, not a unit test"| Key | Default | Variables |
|---|---|---|
ref_single | #{change_id} {path}:{line} | change_id path line |
ref_range | #{change_id} {path}:{start}-{end} | change_id path start end |
ref_span | #{change_id} {path}:{line} {span} | plus span, the selected text |
ref_hunk | #{change_id} {path}:{line} (deleted lines in this hunk) | The cursor on a removed line, which the new file has no number for |
ref_file | {path} | A file too large to render inline, so there is no hunk to point at |
ref_file_line and _range _span | {path}:{line} … | A file with no hunks at all - opened and read rather than reviewed, so no #id |
submit_review | review ready: {path} ({count} comment{s}) | path count s |
ask_why ask_revert ask_test ask_explain | {ref} - why this approach? … | ref, whichever of the above the cursor produced |
{s} on submit_review is the plural: empty for one comment, s otherwise. It is a variable rather than a branch, because a template language with an if in it is a template language.
An unknown placeholder is left verbatim rather than dropped - write {lines} where the table offers {line} and you will see the typo in the message you just sent, instead of a silently shorter one. A key that is not a template is reported with its file and line, like any other config mistake.
A complete example#
[nav]
scrolloff = 5
mark_on_submit = true
[ui]
icons = "nerd"
comments = "inline"
compose = "centre"
scroll_ms = 0
[review]
ignore = ["package-lock.json", "**/*.pb.go"]
[presets]
why = "why this approach?"
perf = "is this hot path allocating?"
[theme]
name = "kanagawa"
fresh = "#ffa066 bold"
[keys]
mark_here = ["gm"]
turn_list = ["<Space>t"]Theming#
[theme]
name = "gruvbox"Bundled themes#
Seven ship with lgtm: terminal, catppuccin, tokyo-night, gruvbox, dracula, rose-pine, kanagawa.
terminal paints nothing and lets your emulator's own sixteen colours through - which is why it is the default.
lgtm --theme-preview # shows them allOverriding one colour#
Any slot can be set by name in the same section. A slot takes a foreground, an optional on <colour> background, and attributes, in any order:
[theme]
name = "gruvbox"
fresh = "#fabd2f bold"
comment_open = "cyan"
cursor_line = "on #3c3836"Colours are #rrggbb, a 0-255 index, or a name (red, bright-blue, …). Attributes are bold, dim, italic, underline, reverse.
Every slot#
| Group | Slots |
|---|---|
| Syntax | text comment string number keyword type_name fn_name punct |
| Accents | accent popup_border |
| Files | file_plain file_added file_deleted file_modified file_renamed file_binary |
| Diff | add_sign del_sign add_line del_line filler hunk_id line_no added_count removed_count |
| Comments | comment_open comment_sent comment_stale |
| The mark | fresh |
| Chrome | rule dim path hint notice prompt mode_badge turn_badge |
| Selection | cursor_line selection search_match |
The diff wash colours (add_line, del_line, filler) are mixed from the theme by default rather than written per theme - see [diff] under Configuration for how, and set them here to override.
Keybindings#
Any command can be bound to any sequence, spelled the way ? prints it:
[keys]
next_hunk = ["]h", "<Space>nh"]
mark_here = ["gm"]
turn_list = ["<Space>t"]
compose_presets = ["<C-p>"]A command with no entry keeps its defaults. An entry replaces them, so listing one spelling removes the others.
Spellings#
A bare character (j, ], ?), <C-x> for control, <Space> for the leader, and <CR> <Esc> <Tab> <S-Tab> <BS> <Up> <Down> <Left> <Right> for the named keys. A sequence is those run together: ]h, <Space>nc, gg.
Conflicts are reported, not resolved. Binding something to <Space>d when <Space>dc exists makes one of them unreachable, and lgtm says so on the status line rather than picking a winner.
These names are also what : takes: :next_file runs the command whether or not a key is bound to it, and Tab completes them. Typing one is the quickest way to check a spelling before committing it to a config file.
Every command#
Moving — line_down line_up page_down page_up top bottom char_left char_right word_next word_prev word_end big_word_next big_word_prev big_word_end line_start line_end first_non_blank find_char till_char find_char_back till_char_back find_repeat find_reverse center
Jumping — next_hunk prev_hunk next_file prev_file next_comment prev_comment next_risk prev_risk search_forward search_next search_prev search_word search_word_back clear_search
The agent — send_ref compose_ask copy_text copy_text_lines copy_ref copy_ref_lines submit_review
Comments — comment_add comment_view comment_list comment_send comment_delete comment_send_one comment_send_all comment_drop
Turns and the mark — mark_here clear_mark next_fresh prev_fresh next_turn prev_turn turn_list restore_file undo_restore
View — toggle_zen toggle_wrap toggle_split toggle_ignored expand_file collapse_file file_list file_browse help refresh open_editor visual_toggle visual_char_toggle visual_cancel command_line quit
Lists — list_down list_up list_left list_right
The compose box — compose_submit compose_cancel compose_send_now compose_presets compose_mention compose_newline
The compose box's two rules#
The box's keys are bindings like any others, but with two rules of their own.
They must be single chords - a text box cannot hold a prefix while waiting to see whether a sequence completes, because the next key is usually a letter you are typing. And a pending operator wins: with d waiting for a motion, <Esc> cancels the operator rather than the box.
The box's motions are not remappable, and that is deliberate rather than unfinished. In a text box every printable key is data, so a keymap able to bind x would be a keymap able to take x away from typing.
Terminals#
Enter types a reference into your agent's input box. Whether that is possible depends on the terminal you are in.
Which terminals lgtm can type into#
| Terminal | How | Notes |
|---|---|---|
| tmux | send-keys | Also carries the clipboard, because tmux's default set-clipboard external swallows an application's OSC 52 |
| herdr | herdr pane send-text | Pane ids are w1:p1. Built for agents, so send-text inserts and never submits |
| WezTerm | wezterm cli send-text | Detected by $WEZTERM_PANE |
| kitty | kitten @ send-text | Needs allow_remote_control yes in kitty.conf - off by default, on purpose |
| Ghostty | AppleScript | macOS, Ghostty 1.3+. Needs Automation access the first time - macOS will ask |
| anything else | OSC 52 | Enter copies instead of sending. Still a paste away |
Alacritty, iTerm2, Terminal.app and the rest have no way for one program to type into another's split, so lgtm falls back to the clipboard there. Run tmux or herdr inside them and you get the full loop - which is what most people already do, and why both are detected ahead of the terminal they are running in.
Telling lgtm which pane your agent is in#
lgtm infers the only other pane; with more than two it will say so rather than guess. Start it with --pane to be explicit:
lgtm --pane %3 # tmux
lgtm --pane w1:p1 # herdr
lgtm --pane 3 # WezTerm and kittykitty calls its splits windows rather than panes, and lgtm says so too - the flag is still --pane, because it is one flag.
Ghostty is a special case#
It gained an AppleScript dictionary in 1.3, so lgtm can type into another split - on macOS, and after you allow it in System Settings → Privacy & Security → Automation.
Ghostty is also the one terminal that does not tell a pane which pane it is: there is no $GHOSTTY_PANE. lgtm takes the focused split as its own the first time it needs to know, which is right because that is the split you just typed lgtm into - but if you have moved focus first, pass --pane N:
osascript -e 'tell application "Ghostty" to get id of every terminal'kitty says "set allow_remote_control yes"#
kitty refuses to let any process type into your terminal until you allow it. Put allow_remote_control yes in kitty.conf and restart it. Until then Enter degrades to the clipboard, which still works; it is a paste away rather than a keystroke away.
Troubleshooting#
"not a git repository"#
lgtm reads git diff for a living. The screen shows which directory it means, so you can tell a wrong cd from a directory that needs git init. It still starts, and still lets you talk to the agent or quit.
A setting was ignored#
A bad key is reported on the status line with the file and the line. It never stops lgtm starting, and it only costs that one key its value.
Yank says copied but the clipboard is empty#
Under tmux, application OSC 52 is discarded unless set-clipboard allows it. lgtm uses tmux load-buffer instead, so this should not happen; if it does, check that tmux is on your PATH.
Nothing is sent to the agent#
lgtm needs to know which pane your agent is in. It infers the only other one; with more than two it will say so rather than guess. Start it with --pane to be explicit - see Terminals for the spelling your terminal uses.
Snapshots are not happening#
They need a git repository, and a turn is taken ten seconds after the agent stops writing. Changes made in the first half-second of a session are part of the starting state rather than a turn.
lgtm --base main --target HEAD is static by design - two committed trees, so the watcher, the snapshots and the mark are all off. The badge says main..HEAD when you are in that mode.
A comment cannot be placed#
Comments follow the code when the agent rewrites it, and when they can no longer be anchored they say so rather than disappearing. The comment_stale theme slot is what marks them; Space lc lists every comment including those.
Getting rid of the snapshot refs#
They are ordinary git refs under refs/lgtm/**, invisible to git branch and git status (though git log --all walks every ref and will show them). Deleting the refs is all it takes:
git for-each-ref --format='%(refname)' refs/lgtm | xargs -n1 git update-ref -dThe objects go when git gc next runs.
Comments#
A dozen remarks is a dozen interruptions, or it is one file.
Collecting them#
Space c]c[cSpace vcSpace lcSpace scSpace dcCtrl-s.lgtm/review-3.mdand tell the agent about itIn the comment list,
Ctrl-ssends the highlighted one,Ctrl-xsends every open one as the review file, andCtrl-ddeletes one.They follow the code#
Comments follow the code when the agent rewrites it, survive a restart, and say so when they can no longer be placed - a comment is never silently dropped.
Re-anchoring is measured, not hoped for: 100% across the fixture set (24 of 24), at 1.8 ms per 50 comments.
How they are drawn#
[ui] commentsdecides."marker", the default, is the gutter dot alone;"inline"folds the comment text under the line it belongs to.[ui] comments = "inline"Submitting#
Ctrl-swrites.lgtm/review-N.md, tells the agent it is there, and takes the mark at the same time - so the next thing the agent writes is visibly new. Set[nav] mark_on_submit = falseif you want a mark that spans several rounds instead.