lgtm - Read What Your Agent Wrote#

lgtm GitHub Repository

██╗      ██████╗ ████████╗███╗   ███╗
██║     ██╔════╝ ╚══██╔══╝████╗ ████║
██║     ██║  ███╗   ██║   ██╔████╔██║ 👍
██║     ██║   ██║   ██║   ██║╚██╔╝██║
███████╗╚██████╔╝   ██║   ██║ ╚═╝ ██║
╚══════╝ ╚═════╝    ╚═╝   ╚═╝     ╚═╝

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.

lgtm running beside a coding agent

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 - Enter sends #3 src/auth.zig:47 into 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:

MeasurementResult
Binary766 KB, one dependency (libSystem)
Frame0.30 ms at 80x26
Re-diff40 ms: one git diff plus the parse
Re-anchoring comments100% across the fixture set (24/24), 1.8 ms per 50

Where to go next#

PageWhat's there
InstallationEvery install path, and what else you need
Getting StartedFirst run, the loop in five minutes
Reading the DiffMotions, search, hunks, files, views
Talking to the AgentReferences, the compose box, presets
CommentsCollect a dozen remarks, submit once
The MarkWhat arrived since you last read, and weakened tests
SnapshotsTurns, restore, and getting your work back
Keyboard ShortcutsEvery key, in one place
ConfigurationEvery setting in config.toml
ThemingSeven bundled themes and every colour slot
KeybindingsRebind anything; every command name
TerminalsWhich terminals lgtm can type into
TroubleshootingWhen something doesn't work

Installation#

macOS#

From the tap:

bash
brew install kunkka19xx/tap/lgtm

Linux 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.

bash
curl -fsSL https://raw.githubusercontent.com/kunkka19xx/lgtm/main/scripts/install.sh | sh

The 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.

bash
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 --uninstall

Arch Linux#

From the AUR, with whichever helper you use, or none:

bash
paru -S lgtm-bin     # or: yay -S lgtm-bin
git clone https://aur.archlinux.org/lgtm-bin.git && cd lgtm-bin && makepkg -si

lgtm-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:

bash
nix run github:kunkka19xx/lgtm          # run it once, install nothing
nix profile add github:kunkka19xx/lgtm  # keep it on PATH

nix 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:

bash
make local          # build and install to ~/.local/bin
make clean-local    # remove it, restoring whatever it displaced

make 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#

bash
lgtm -v          # version, author, repository
lgtm             # in any git repository

In 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#

StepKeyWhat happens
Readj k, ]h, /The diff against HEAD - staged, unstaged and untracked
PointEnterA reference like #3 src/auth.zig:47 lands in the agent's input box
CommentSpace cNotes collect on the lines they belong to
SubmitCtrl-sWrites .lgtm/review-3.md, tells the agent, and marks what you read
Come back]mThe 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:

bash
lgtm --init                              # ~/.config/lgtm/config.toml
lgtm --init --config .lgtm/config.toml   # this repository's, meant to be committed

Every 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#

CommandWhat it shows
lgtmHEAD against the working tree - the default, and what the tool is about
lgtm --base mainYour 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 HEADCommitted 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:

FileWhat it holds
.lgtm/comments.jsonlYour comments
.lgtm/review-N.mdWhat Ctrl-s wrote
.lgtm/state.jsonThe session, the turn count, where you read to
.lgtm/config.tomlThis 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.

/ 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 side-by-side layout

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

The flow view

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.

The changed-files list

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:47

Type 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.

KeyWhat it sends
V then EnterA line range: :47-52
v then EnterThe selected words themselves
Space aThe box, with your [presets] list already open
Space yThe reference to the clipboard instead of the agent
Space YThe reference and the lines
y YYanks 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.

KeyWhat it does
EnterSend
Ctrl-iInsert a preset at the caret
@Insert a file path
Ctrl-jLine break
Ctrl-sSave 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:

toml
[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:

toml
[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"

Comments#

A dozen remarks is a dozen interruptions, or it is one file.

Collecting them#

KeyWhat it does
Space cWrite a comment on this line - on removed code too
]c [cWalk them
Space vcOpen the nearest one to read or edit
Space lcList every comment; the filter reaches the file, the line and the text
Space scSend just this one, now
Space dcDelete the one here
Ctrl-sWrite .lgtm/review-3.md and tell the agent about it

In the comment list, Ctrl-s sends the highlighted one, Ctrl-x sends every open one as the review file, and Ctrl-d deletes 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] comments decides. "marker", the default, is the gutter dot alone; "inline" folds the comment text under the line it belongs to.

toml
[ui]
comments = "inline"

Submitting#

Ctrl-s writes .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 = false if you want a mark that spans several rounds instead.

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#

KeyWhat it does
]t [tWalk the turns, ending at the working tree
Space ltList them: what each touched, when, how big
RRestore this file from the turn on screen
uUndo 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#

MarkerMeaning
↺ 4The 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:

bash
git show refs/lgtm/<session>/0:src/auth.zig    # the pre-agent version
git log --graph refs/lgtm/<session>/7          # the session

They 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#

toml
[snapshot]
keep = 36

Turns 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.

The help overlay, generated from your own bindings

Moving#

KeyWhat it does
j kDown and up a line
h lLeft and right a character
w b eNext, previous, end of word
W B EThe same over WORDs - only blanks separate
0 ^ $First, first non-blank, last column
f t F TTo or before a character on this line
; ,Repeat the last jump, either way
Ctrl-d Ctrl-uHalf a page
gg GFirst and last line
} {Next and previous break: a blank line, or a hunk edge
zzCentre the cursor line

Jumping#

KeyWhat it does
]h [hNext and previous hunk (wraps)
]f [fNext and previous file (wraps)
]c [cNext and previous comment
]m [mNext and previous change since the mark
]t [tNext and previous turn
]w [wNext and previous weakened test
/ n NSearch the review, then step
* #Search for the word under the cursor, forwards or back
Space fThe changed files
Space FEvery file in the project

Every ]x has a Space nx spelling and every [x a Space px.

Talking to the agent#

KeyWhat it does
EnterCompose a message about this line
Space aCompose, with the question list open
Space y Space YCopy the reference, or the reference and the lines
y YYank the selection, or whole lines
v VVisual select, characters or lines

Comments#

KeyWhat it does
Space cWrite one here
Space vcOpen the nearest to read or edit
Space lcList every comment
Space scSend this one on its own
Space dcDelete the one here
Ctrl-sSubmit the review, and mark what you read

Turns and the mark#

KeyWhat it does
mMark: everything after this is new
MDrop the mark
Space ltList the turns. Typing a number finds that turn; typing a word searches the row
RRestore this file from the turn on screen
uUndo the last restore
]w [wWalk the weakened tests

View#

KeyWhat it does
TabZen: hide the chrome
zwSoft wrap long lines
Pipe key or -Side by side, or back to the flow view
H LSide by side: focus the old or the new column
ziShow the files [review] ignore hides
zo zcOpen a file too large to render inline, or fold it
Ctrl-rRe-diff now
Space eOpen this line in $EDITOR
?Every key, from your bindings
:Run any command by name; Tab completes
:qQuit

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              :nomark

Tab 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's

lgtm --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#

KeyDefaultWhat it does
hunk_crosses_filestrue]h carries into the next file at the end of one. False keeps hunk motions inside the current file, wrapping there
scrolloff3Rows 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_submittrueCtrl-s takes the mark as well as sending the review. False for a mark you want to span several rounds
toml
[nav]
hunk_crosses_files = true
scrolloff = 3
mark_on_submit = true

diff#

KeyDefaultWhat 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_width100Below 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
toml
[diff]
layout = "auto"
highlight = "line"
split_min_width = 100

The 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#

KeyDefaultWhat it does
wraptrueSoft 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_ms250How long a jump takes to travel, up to 1000. 0 turns the animation off
cursor_ms80The same for the cursor
toml
[ui]
icons = "nerd"
comments = "inline"
compose = "bottom"
scroll_ms = 0        # instant

review#

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.

toml
[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.

toml
[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#

toml
[snapshot]
keep = 36

Turns 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.

toml
[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"
KeyDefaultVariables
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_reviewreview 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#

toml
[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#

toml
[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.

bash
lgtm --theme-preview     # shows them all

Overriding 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:

toml
[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#

GroupSlots
Syntaxtext comment string number keyword type_name fn_name punct
Accentsaccent popup_border
Filesfile_plain file_added file_deleted file_modified file_renamed file_binary
Diffadd_sign del_sign add_line del_line filler hunk_id line_no added_count removed_count
Commentscomment_open comment_sent comment_stale
The markfresh
Chromerule dim path hint notice prompt mode_badge turn_badge
Selectioncursor_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:

toml
[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#

Movingline_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

Jumpingnext_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 agentsend_ref compose_ask copy_text copy_text_lines copy_ref copy_ref_lines submit_review

Commentscomment_add comment_view comment_list comment_send comment_delete comment_send_one comment_send_all comment_drop

Turns and the markmark_here clear_mark next_fresh prev_fresh next_turn prev_turn turn_list restore_file undo_restore

Viewtoggle_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

Listslist_down list_up list_left list_right

The compose boxcompose_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#

TerminalHowNotes
tmuxsend-keysAlso carries the clipboard, because tmux's default set-clipboard external swallows an application's OSC 52
herdrherdr pane send-textPane ids are w1:p1. Built for agents, so send-text inserts and never submits
WezTermwezterm cli send-textDetected by $WEZTERM_PANE
kittykitten @ send-textNeeds allow_remote_control yes in kitty.conf - off by default, on purpose
GhosttyAppleScriptmacOS, Ghostty 1.3+. Needs Automation access the first time - macOS will ask
anything elseOSC 52Enter 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:

bash
lgtm --pane %3        # tmux
lgtm --pane w1:p1     # herdr
lgtm --pane 3         # WezTerm and kitty

kitty 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:

bash
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:

bash
git for-each-ref --format='%(refname)' refs/lgtm | xargs -n1 git update-ref -d

The objects go when git gc next runs.