Pager authors reviewing the protocol asked to drop the EMIT_ prefix: the
OSC number already names the protocol, so EMIT_ was redundant. Rename the
handshake variable to OSC1717_METADATA in the spec and in the two places
lazygit advertises it (the pager PTY and the metadata probe).
The terminal-allocation audit settled the protocol's OSC number on 1717 (unused
by every surveyed terminal — see diff-line-metadata-osc-spec.md), retiring the
456 placeholder. Rename the host side to match: the gocui carrier that accumulates
and reads back the sequence, the parser, and the handshake env var the pager
subprocess is given (now EMIT_OSC1717_METADATA). Flip the design notes and spec
from 'rename pending' to done.
The delta and difftastic emitters are renamed in their own repos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a main view re-renders content different from what it last showed, the
scroll resets to the top. That reset fired synchronously when the task started —
but with the off-screen render the previous content stays displayed until the
swap, so resetting the origin up front scrolled that still-visible content to the
top before the new content replaced it: a distracting jump when switching commits
(or any item) while scrolled down.
Defer the reset to the task's first paint (the swap), alongside the restore that
already runs there: the previous content stays at its scroll until the new content
takes its place, then the new content appears at the top. A same-content re-render
keeps its scroll (no reset); a restore places the scroll itself. The "loading..."
indicator path also resets the origin now, since it clears the previous content to
show the message and must put it at the top.
The reset moves out of NewTask (it no longer needs the task key or the pending
restore for this) into the read loop, driven by LinesToRead.ResetOrigin, which the
cmd/pty wrappers set from the key comparison the reset used to do. The manager's
onNewKey callback is renamed resetOrigin to match its now-decoupled timing.
Escaping a patch explorer (staging / patch building) back to the focused main
view it was entered from used to replay a numeric scroll position and selection
index captured on the way in. But the reason to escape after staging or dropping
a hunk is that the content changed, so a saved index points at the wrong line —
and the host auto-advances the explorer's selection to a still-valid line anyway,
which is the line the user actually cares about returning to.
Restore by *patch identity* instead. On escape, read the (file, type, source
line) the explorer currently has selected, then have the main view's re-render
land on the row that matches it: scan the incoming content as it loads (the
inverse of the diff-line primitive), and once the matching row plus a screenful
below it have loaded, swap the off-screen render in and scroll to / select that
row in one step. FocusPoint with scrollIntoView centres the row only if it's
off-screen, so the common unchanged-content escape — where the row is already
where it was — doesn't move at all. If the line is gone (the content really
changed), nothing is forced.
This generalizes the scroll restore from a fixed origin to a predicate
(RenderRestore: FirstPaintReady decides when the saved position is reachable,
Apply re-establishes it), folding the separate selection restore into the same
first paint — so it no longer rides a post-load callback that could fire early.
The restore also now survives task replacement, which the numeric version did
not: a periodic refresh can stop the escape's re-render before it first-paints.
The pending restore is held on the buffer manager and is *not* cleared when a
task starts, so the replacement task picks it up. It is not gated on the command
key — staging the last unstaged hunk re-renders `git diff` as `git diff --cached`,
a different command, yet the line to land on is still in the new content — but
validates itself: the scan finds the target line only when the content still
contains it, so applying it to a different item is a harmless no-op. A task
clears it once it has applied it (found or not), so it lives for exactly one
re-render. Because the restore is anchored on content identity and is idempotent,
"survive replacement" and "restore by identity" are one mechanism, not two.
With the identity in hand the snapshot no longer needs the captured scroll/index;
they're derived from the explorer's live selection.
A cmd/pty re-render used to overwrite the displayed buffer from the top
down as lines arrived, relying on keeping the previous render's view-line
tail to avoid a blank frame. That left the view showing a mixture of old
and new content while loading, and any reader (draw, the diff-line
mapping, clicks) could observe a half-written buffer at the wrong scroll —
the §11 Race A flicker and the §8 stale-tail mapping both came from this.
Instead, build the new content in a second, off-screen viewBuffer: until
the task has read enough to paint, writes go there and the displayed
buffer — and so everything every reader sees — is left untouched. Once the
task reaches its first-paint point (InitialRefreshAfter, or EOF for short
content) it swaps the off-screen buffer in atomically and applies the
saved scroll in the same step, so the view jumps straight from the
previous render to the new one with no intermediate frame. Subsequent
lines append to the now-displayed buffer.
Swapping at the first-paint point means the displayed buffer is only a
viewport tall when it appears and then grows as the rest streams in toward
the count needed for an accurate scrollbar. The scrollbar is sized from the
displayed buffer's height, so left to itself the thumb would shrink and
snap back during that growth (most visibly: the files panel's periodic
refresh making the thumb jump while scrolled down). The total height the
scrollbar needs is a strictly later quantity than the viewport-fill paint,
so no single early swap can have both right. FreezeScrollbarHeight therefore
records the view's height when a load begins and the scrollbar is held there
— growing only if the new content turns out taller — until the load ends; a
synchronous render superseding the load releases it. This mirrors the layout
clamp, which already ignores the partial content height while a view loads.
With the swap doing a wholesale replace, refreshViewLinesIfNeeded can
truncate the view lines to the current buffer: there is no longer a
half-loaded shorter buffer whose tail we must keep showing, so the stale
tail (§8) never forms. clear()/Reset() abandon any in-progress off-screen
render so a synchronous SetContent after a stopped task writes to the
display.
This replaces the holdViewLines and freshViewLineCount patches reverted in
the previous two commits with one mechanism. The swap holds writeMutex for
now; it could later move to the main thread. Flicker behaviour still needs
interactive verification (LAZYGIT_SLOW_RENDER + a real pager).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This reverts the holdViewLines flicker patch (Race A). It is about to be
superseded by rendering a re-render into an off-screen buffer and swapping
it in atomically, which keeps the displayed buffer (and so every reader)
untouched until the new content is ready — a cleaner mechanism than
suppressing the view-line rebuild. Removing it on its own keeps that
upcoming change focused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When escaping to a focused main view scrolled down, the re-render keeps
the previous content as a placeholder and the task scrolls to the saved
position at its first paint. But the task's writes mark the view tainted,
so any layout pass landing between the first write and the first paint
rebuilds the view lines from the half-loaded buffer and draws them at the
placeholder's scroll — a brief frame of the wrong content at the wrong
scroll before it snaps into place. Intermittent, and only visible when the
load is slow enough for a layout pass to fall in that window.
Give the view a hold: while set, refreshViewLinesIfNeeded keeps the
current view lines instead of rebuilding from the buffer, so the view goes
on drawing the coherent placeholder. The re-render task sets it only when
restoring a scroll position (so the normal load-from-top case is
untouched) and releases it at its first paint, which applies the saved
scroll in the same step — so the loaded content appears at the restored
scroll with no intermediate frame. While held, the displayed view lines
need not match the loading buffer, so the view-line→buffer-line mapping
reports no result.
This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager);
see focused-main-view-notes.md §13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Escaping to a focused main view restored the selection by scheduling, on
the next UI tick, a ReadToEnd whose callback re-selected the saved line.
But ReadToEnd fires its callback synchronously when the manager has no
live read channel, and the re-render task triggered by the push creates
that channel later, inside its own goroutine (after stopping the previous
task). If the UI tick won that race, the restore ran before any content
was loaded, FocusPoint no-oped against the unloaded line, and the
selection was silently dropped — intermittently, and more often under
load.
Thread the restore through the task instead: a thenForNextTask hook on
the buffer manager, folded into the next cmd/pty task's initial-read Then,
mirroring scrollToOriginYForNextTask. It runs once the task has read
enough to place the selection, and can't fire before the task exists. The
scroll restore already applies at the task's first paint, which precedes
the initial read's end, so the origin is in place when the selection is
restored.
This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager);
see focused-main-view-notes.md §13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add mechanism #2 to GetDiffLineInfo: when a patched pager annotated each diff
line with OSC 456 metadata, read it back as the first backend, ahead of the
buffer parser (#1) and the lazygit-edit hyperlink. It is strictly higher
fidelity -- it carries the side explicitly, so it serves the renderings #1
cannot parse (delta's default mode, --line-numbers, diff-so-fancy) and conveys
deletions, which the hyperlink can't.
The host advertises the protocol versions it understands by setting
EMIT_OSC456_METADATA on the pager subprocess; a pager that doesn't understand
it ignores the variable, so this is safe to set unconditionally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When re-rendering content the user was already scrolled into, we want the
saved scroll position applied exactly when the real content first paints — not
before. Setting the origin up front instead paints it onto whatever placeholder
is currently in the view (e.g. the shorter buffer CopyContent left there),
which flickers: either a blank frame past the placeholder's end, or a jump to
the top when the task resets the origin at startup.
Add ViewBufferManager.ScrollToOriginYForNextTask: the next cmd/pty task then
(a) does not reset the view to the top at startup even though the command key
changed, so the placeholder stays put, (b) sizes its initial read to the saved
position so enough content is loaded to fill the view there, and (c) scrolls to
it as part of the first refresh, in the same paint that shows the real content.
This is the cmd/pty analogue of RenderStringWithScrollTask.
No caller sets it yet, so this is behaviour-preserving on its own.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The layout scrolls a view up if its origin is past the bottom of its
content, to avoid showing blank space (e.g. after a resize). But it measures
content height by the lines loaded so far, and command/pty tasks load
asynchronously. So when a view is re-rendered while scrolled down, the layout
would yank it to the top because only a fraction of the content has been read
yet, then leave it there once loading finished.
Track whether a command task is actively reading (set synchronously when the
task is created, so a layout pass in between sees it; cleared at EOF, but not
when stopped, since that means a newer task is taking over) and skip the
scroll-up clamp for such views. onEndOfInput already re-clamps once loading
completes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The task stop path terminates the still-running command by pulling its
*os.Process out of the Cmd interface and applying one global strategy
(TerminateProcessGracefully) to it. That shape can't accommodate the
upcoming fix for orphaned process trees on Windows: there, stopping a
pty task requires terminating the entire process tree via a job object
whose handle lives with the pty, not with the process. And the two Cmd
implementations genuinely need different strategies anyway: a
process-group kill (the likely future fix for #5675 on Unix) is only
safe for pty children, which run as session leaders, while plain
commands share lazygit's own process group.
So let each Cmd implementation decide how to terminate itself, and drop
GetProcess, which had no other callers. No change in behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At the end of a diff against the worktree, git re-reads and refreshes
the index and writes it back if it found stale stat information
(diff.autoRefreshIndex, on by default). It holds index.lock for the
whole refresh; GIT_OPTIONAL_LOCKS does not cover this lock, and the
window scales with the size of the repository (~150ms for a 6k-file
repository with a warm stat cache).
On Windows, a pty task that is stopped because the user moved on
terminates its git process at an arbitrary point: tearing down the
pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the
default handler, which simply calls ExitProcess. If that lands inside
the refresh, a stale index.lock is left behind and the next git
command chokes on it. This is the same problem that 98801da106 fixed
by no longer killing git processes; the ConPTY support added in 0.63
reintroduced it through the close event.
Disable the automatic refresh for pty-rendered commands. They can
afford it: the refresh only persists refreshed stat information, and
lazygit's foreground git status refreshes -- which never run in a pty
and are never killed -- already write that back on every user action
and on terminal focus-in. The cost is that while the on-disk stat
cache is stale, an external differ is invoked even for files whose
stat information changed but whose content didn't, showing them as
empty diffs; this heals with the next foreground refresh, which also
re-renders the view.
Unix keeps the refresh: a stopped pty child gets SIGTERM there, and
git's signal handlers remove its lock files, so the lock window is
harmless. The rawGit renderer keeps it too: its tasks don't run in a
pty and are never killed on Windows -- they either run to completion
or die on a broken pipe mid-output, before the refresh begins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For a long time lazygit has used the term "custom pager" to refer to
what's really a "diff renderer". A pager is a program that allows you to
view output page by page (hence the name), e.g. less; lazygit's custom
diff renderers are not pagers. It used the term only because the feature
is implemented using git's GIT_PAGER env var, but that's an
implementation detail.
Rename the 'git.pagers' config to 'git.diffRenderers', and restructure
its elements while we're at it to make things clearer:
- Add a 'type' field to explicitly specify which type of diff renderer
it is (the two fundamentally different ones are 'stdinFilter' and
'extDiff').
- Add a third type, 'rawGit', which has an 'args' field that makes it
easy to use 'git --color-words' as a custom renderer
- Unify the old 'pager' and 'externalDiffCommand' fields to a single
'command' field for both types
Existing config files are migrated automatically.
NewCmdTask feeds the reader returned by its start func into a
bufio.Scanner, and Scanner.Scan panics with a nil pointer dereference
when that reader is nil. Two start funcs could produce one:
- newPtyTask's fallback for a failed StartPty returned a literal nil
reader, alongside an ExecCmd that was never started, so the intended
"fall back to a plain cmd task" never worked. This crashed lazygit on
Windows when using a custom pager with the main view zero-sized, e.g.
after pressing + twice to enter full-screen mode with a side panel
focused: ConPTY rejects zero dimensions, making StartPty fail.
- startCmdWithPipe returned nil when the pipe couldn't be created,
which the Unix pty fallback path can trigger, since a failed pty
start can leave the tty assigned to the command's stdout.
Make startCmdWithPipe never return a nil reader: when the pipe can't be
created, don't start the command at all and return an empty reader so
the task shuts down cleanly with the error in the log. Then route
newPtyTask's fallback through it, so a StartPty failure degrades to
running the command without a pty: the pager is lost, but the command's
output still renders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A command task streams its output into a view from its own goroutine. To
track soft-wraps (so cursor-positioning escapes from a pager land on the
right line) the write path read the view's live InnerWidth, and the pty
setup read its InnerSize -- both off the UI thread, racing the UI thread
mutating the view's dimensions during layout.
Capture the width on the UI thread instead and hand it to the task: the
escape interpreter keeps a screenColMax it reads from, seeded in NewView
and refreshed per render via View.SetContentWidth (called from
newCmdTask/newPtyTask before the task's goroutine starts), and the pty
size is computed in the after-layout callback rather than in the task's
start func. The view's dimensions stay UI-thread-only; the task uses the
snapshot rather than reading them live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the StartPty stub with a real ConPTY implementation:
CreatePipe + CreatePseudoConsole + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
+ CreateProcess. Pagers and external diff tools now get real terminal
behavior instead of being handed pipes.
One Windows-specific quirk worth flagging: ConPTY does not EOF the
output pipe when the child exits; conhost keeps it alive until
ClosePseudoConsole is called explicitly. A background waiter goroutine
calls ClosePseudoConsole as soon as proc.Wait returns, so callers see
EOF on outRead — restoring the Unix master-fd-EOFs-when-slave-closes
semantics they depend on.
The ErrPtyUnsupported sentinel and the no-pty fallback in newPtyTask
are gone now that both platforms have a real implementation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the pty master behind a small interface (Read/Write/Close/Resize),
and push the actual startup into a platform-specific StartPty function
in pkg/commands/oscommands. The Unix implementation still uses
creack/pty; the Windows implementation is a stub that returns
ErrPtyUnsupported, at which point newPtyTask falls back to a plain cmd
task — matching the existing Windows behavior.
The primitive lives in oscommands rather than pkg/gui because the
cmd_obj_runner pty handler (also in oscommands) is going to consume it
too, and tasks → oscommands is the existing dependency direction.
Same observable behavior on every platform; this just carves out a seam
for a real ConPTY implementation on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows ConPTY can't attach a child process to a pseudoconsole via
os/exec — Go's stdlib doesn't expose PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
(golang/go#62708). The ConPTY path has to call CreateProcess directly,
so it can't hand an *exec.Cmd back to the task runner.
Widen NewCmdTask to accept a small Cmd interface satisfied by both
*exec.Cmd (via the ExecCmd adapter) and the Windows ConPTY command type
we're about to add. Change TerminateProcessGracefully to take
*os.Process, which both cmd shapes can provide.
Behavior is unchanged on every platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The env var was previously set only on Windows, where the no-op pty
stub was just running the command without a pty and needed to expose
the width to pager scripts another way. With ConPTY coming to Windows
the rationale disappears there, but the env var is documented in
docs/Custom_Pagers.md for pager scripts that can't query the terminal
width directly. Set it on every platform so those scripts remain
portable, regardless of whether a pty is in play.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
I copied all files except dot files (.github and .gitignore), the _examples
folder, and go.mod/go.sum.
At some point we may want to copy the files back to the gocui repo when other
clients (e.g. lazydocker) want to use the newer versions of them.
This is an object that is owned by Gui, is accessible through GuiCommon.State(),
and also passed down to GitCommand, where it is mostly needed. Right now it
simply wraps access to the Git.Paging config, which isn't very exciting, but
we'll extend it in the next commit to handle a slice of pagers (and maintain the
currently selected pager index), and doing this refactoring up front allows us
to make that change without having to touch clients.
In 8b8343b8a9 we made a change to run newPtyTask from AfterLayout; this is
needed so that the PTY gets the new, updated view size. However, this created a
race condition for integration tests that select a line in a list view and then
expect the main view to have certain content; sometimes that content gets
rendered too late.
I'm surprised that this didn't cause more tests to fail; right now I only know
of one test that occasionally fails because of this, which is stash/rename.go.
Fix this by moving the AfterLayout to inside newPtyTask, and do it only when we
are actually using a PTY (we don't when no pager is configured, which is the
case for integration tests).
The diff is best viewed with "ignore whitespace" turned on.
Original commit message of the gocui change:
This fixes View.Size, Width and Height to be the correct (outer) size of a view
including its frame, and InnerSize/InnerWidth/InnerHeight to be the usable
client area exluding the frame. Previously, Size was actually the InnerSize (and
a lot of client code used it as such, so these need to be changed to InnerSize).
InnerSize, on the other hand, was *one* less than Size (not two, as you would
have expected), and in many cases this was made up for at call sites by adding 1
(e.g. in calcRealScrollbarStartEnd, parseInput, and many other places in the
lazygit code).
There are still some weird things left that I didn't address here:
- a view's lower-right coordinates (x1/y1) are one less than you would expect.
For example, a view with a 2x2 client area like this:
╭──╮
│ab│
│cd│
╰──╯
in the top-left corner of the screen (x0 and y0 both zero) has x1/xy at 3, not
4 as would be more natural.
- a view without a frame has its coordinates extended by 1 on all sides; to
illustrate, the same 2x2 view as before but without a frame, sitting in the
top-left corder of the screen, has coordinates x0=-1, y0=-1, x1=2, y1=2. This
is highly confusing and unexpected.
I left these as they are because they would be even more of a breaking change,
and also because they don't have quite as much of an impact on general app code.
All PTYs were created with the size of the main view, on the assumption that
main and secondary always have the same size. That's not true though; in
horizontal split mode, the width of the two views can differ by one because of
rounding, and when using a pager that draws a horizontal line across the width
of the view, this is visible and looks very ugly.
We refresh the view after reading just enough to fill it, so that we see the
initial content as quickly as possible, but then we continue reading enough
lines so that we can tell how long the scrollbar needs to be, and then we
refresh again. This can result in slight flicker of the scrollbar when it is
first drawn with a bigger size and then jumps to a smaller size; however, that's
a good tradeoff for a solution that provides both good speed and accuracy.
more
and more
move rebase commit refreshing into existing abstraction
and more
and more
WIP
and more
handling clicks
properly fix merge conflicts
update cheatsheet
lots more preparation to start moving things into controllers
WIP
better typing
expand on remotes controller
moving more code into controllers