Skip to content

feat(pager): commit-by-commit review with streaming git log -p#254

Open
bezhermoso wants to merge 28 commits intomodem-dev:mainfrom
bezhermoso:feat/pager-stream-prototype
Open

feat(pager): commit-by-commit review with streaming git log -p#254
bezhermoso wants to merge 28 commits intomodem-dev:mainfrom
bezhermoso:feat/pager-stream-prototype

Conversation

@bezhermoso
Copy link
Copy Markdown

Note

This is a working implementation of this proposal: #247

Summary

git log -p | hunk pager becomes an interactive commit-by-commit review surface. The full review chrome — sidebar, menu bar, filter, agent surface — turns on, the cursor (> / <) walks through commits one at a time, and each commit's diff is parsed lazily via a streaming pipeline so memory and startup time stay bounded even on multi-thousand-commit histories. A --no-review opt-out preserves the original "timeline scroll" experience for users who only want to read history.

What this does

  • Commit-by-commit review. Pipe git log -p into hunk pager and review one commit at a time with the same chrome a regular review session has: sidebar, menu bar, filter, theme cycling, agent notes, hunk navigation. > and < advance and rewind the cursor; the canvas content swaps while the chrome stays put.
  • Persistent commit header strip. A header strip above the file list shows the active commit's sha, subject, author, and body. A tri-state toggle (C) cycles "full / compact / hidden" so the strip can collapse to a one-line ribbon or disappear entirely.
  • Streaming pipeline. Stdin is consumed as it arrives. Files render in the sidebar and main pane while the producer is still running. Pull-paced reading with hybrid commit/file watermarks bounds memory at any moment to the user's reading position plus a small lookahead — git log -p blocks on its own pipe write until the user advances.
  • Daemon-aware navigation. Each commit-cursor move refreshes the broker's view of the active commit (same session id, swapped file list) so MCP-driven agents always see the current commit. The agent surface stays alive across the full log.
  • Persistent state across commit nav. View-mode preferences and inline notes the user has left on a commit survive the cursor move. Notes are bucketed by commit sha; nav away and back and they reappear. Filter and scroll deliberately reset on each move because they don't translate across commits. The old "comments will be discarded if you move" confirmation prompt is gone — nothing is lost on move now.
  • --no-review opt-out. Auto-detected for non-commit-log inputs, or explicit via --no-review. Skips the daemon, skips the agent surface, and runs scroll-only chrome — the right shape for "I'm reading history, not reviewing." Streaming and the agent surface are mutually exclusive by construction, which removes a class of staleness regressions in the broker view.
  • Kill switch. HUNK_PAGER_STREAM=0 falls back to the pre-streaming buffered path while the new code stabilizes.

Workstreams

The work landed as a stack of phases. Each is a coherent step but they jointly deliver the feature. This PR contains all the phases, but happy to split this up to make it easier to review. Just let me know.

Streaming pipeline foundation

  • Incremental stdin reader + bounded patch sniffer
  • Per-file streaming chunker + debounced changeset stream
  • Commit-boundary detection captured as inline metadata
  • Late-subscriber replay buffer, kill switch, abort-on-unmount
  • Inline commit metadata rendered above each commit's first file
  • Pull-paced reading with hybrid commit/file watermarks

Two pager modes

  • --no-review mode (timeline scrolling, no daemon, no agent surface) with auto-detection
  • Default review path stays buffered + daemon-registered

Commit-by-commit review

  • Per-commit producer, cursor, and commit buffer in the app shell
  • > / < cursor navigation with daemon view refresh per commit
  • Full review chrome (sidebar, menus, filter, full keymap) turns on in commit-review mode

Commit-header chrome and persistence

  • Persistent commit header strip above the file list
  • Tri-state commit-details toggle: full / compact / hidden (C)
  • View-mode preference persists across commit-cursor moves
  • Inline notes persist across commit-cursor moves (bucketed by sha)
  • Confirmation prompt for commit-move removed — comments survive the move

Out of scope / follow-ups

  • Rear-side drop policy. Memory growth is now paced by reading speed, but old commits are still retained for scroll-back. Bounding total memory regardless of scroll behavior remains a documented follow-up.
  • Per-commit CLI shortcuts. hunk session next-commit / prev-commit and the SKILL.md update for agent-driven commit nav are not part of this PR.

Test plan

  • Typecheck clean
  • Lint clean
  • Streaming chunker: file-boundary parity with the legacy synchronous splitter on representative fixtures
  • Commit-boundary detection: positive, decorated, negative-with-context-lookalike, ANSI-stripped
  • Replay-buffer + back-pressure watermark behavior
  • App-shell streaming append: selection stability across appends
  • Routing: auto-detect git log -p pipe by content, --no-review, --review, plain-diff fallback
  • Manual smoke: pipe vim's git log -p (~10k commits), confirm chrome stays put across >/< and memory tracks reading position
  • Manual smoke: leave notes on commit A, nav to B, nav back — notes restored
  • Manual smoke: --no-review opt-out on plain git diff input

@bezhermoso bezhermoso marked this pull request as ready for review May 8, 2026 17:54
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR wires git log -p | hunk pager into a full commit-by-commit review surface with streaming I/O, back-pressure watermarks, and a persistent commit-header strip. The feature is substantial and the streaming pipeline layer (chunker, sniffer, changeset stream, commit-review stream) is well-built; the two issues below are in the UI orchestration layer.

  • Streaming pipeline: stdinLinespatchSnifferpatchChunkerchangesetStreamcommitReviewStream forms a pull-paced chain that bounds memory to the user's reading position. Auto-routing between commit-review, flat-streaming, and legacy-buffered paths is clean and well-tested.
  • Commit navigation UI: AppHost maintains a commit buffer and cursor; App renders the cursor strip, commit-details header block, and > / < keyboard bindings. Two correctness issues exist here: anonymous commits are silently deduped into the first one (sha key collision on ""), and the "notes persist across moves" feature listed as complete in the workstream checklist is not actually implemented — the confirmation prompt and clearLiveComments() call are still present, and notes are destroyed on commit move.

Confidence Score: 3/5

The streaming pipeline is solid, but two correctness gaps in the commit-navigation UI should be resolved before merge.

The notes-persistence feature is marked complete in the PR checklist but is not implemented: inline notes are still deleted when the user confirms a commit move, and the confirmation prompt itself remains despite the description saying it was removed. Separately, the sha-based dedup in the commit buffer silently drops all anonymous commits after the first. Both issues affect behavior on paths the PR explicitly advertises as working.

src/ui/App.tsx (confirmCommitMove discards notes) and src/ui/AppHost.tsx (anonymous-commit dedup, dead lint-suppression code)

Important Files Changed

Filename Overview
src/ui/App.tsx Adds commit-review chrome: commit cursor display, pending-move confirmation prompt, back-pressure signal, and commit details mode wiring. The confirmation prompt and clearLiveComments call contradict the PR description claiming notes persist across commit moves.
src/ui/AppHost.tsx Central commit-review orchestration: streams subscribe, commit buffer, cursor navigation, daemon refresh, and app remount on move. Anonymous commit dedup is broken (sha-keyed dedup collapses all anonymous commits); lingering lint-suppression dead code at the bottom.
src/core/startup.ts Replaces one-shot stdin buffering with a streaming line source and routes log-style vs plain input through commit-review, flat-streaming, or legacy-buffered paths. noReview is dead code in the legacy path (always undefined by the time it is reached).
src/core/streaming/changesetStream.ts New streaming changeset producer with pull-paced back-pressure, debounced batching, late-subscriber replay, and abort. Logic is well-structured and the watermark math is sound.
src/core/streaming/commitReviewStream.ts Groups flat file-stream events into per-commit CommitChangeset objects; flushes on boundary detection and stream completion. Replay buffer and abort delegation are correctly implemented.
src/core/streaming/patchChunker.ts New streaming patch chunker that emits per-file ChunkEvent objects with optional commit header text. Lookahead for --- /+++ boundaries and commit metadata accumulation are cleanly separated.
src/core/streaming/patchSniffer.ts Bounded prefix sniffer that detects patch vs plain-text input and identifies log-style commit headers. Decision returns the consumed prefix so it can be prepended to the rest of the stream without data loss.
src/core/streaming/commitMetadata.ts Parses verbatim git-log commit header blocks into structured CommitMetadata; provides full, compact, and collapsed render helpers. Empty-sha fallback and Merge: line handling are robust.
src/ui/hooks/useAppKeyboardShortcuts.ts Adds >, <, C, and confirmation-mode key handling for commit navigation. Confirmation intercept is placed at highest precedence and correctly swallows all other keys while pending.
src/ui/lib/fileSectionLayout.ts Adds preambleTop/preambleHeight fields to FileSectionLayout and a getCommitPreambleHeight helper; showCommitDetails propagated through buildFileSectionLayouts for scroll-geometry consistency.
src/core/streaming/stdinLines.ts Streaming stdin to line iterator with UTF-8 incremental decode, CRLF normalisation, and best-effort lock release on teardown. Clean and straightforward.

Sequence Diagram

sequenceDiagram
    participant stdin as stdin (git log -p)
    participant sniffer as patchSniffer
    participant chunker as patchChunker
    participant cs as changesetStream
    participant crs as commitReviewStream
    participant host as AppHost
    participant app as App

    stdin->>sniffer: line stream
    sniffer-->>sniffer: sniff prefix (64KB)
    sniffer->>chunker: chainLines(prefix, rest)
    chunker->>cs: "ChunkEvent{chunkText, commitHeaderText}"
    cs->>cs: parse + batch DiffFiles
    cs->>crs: onAppend(files)
    crs->>crs: accumulate files per commit
    crs->>host: onCommit(CommitChangeset)
    host->>host: append to commitBuffer
    host->>app: setActiveBootstrap + bump appVersion
    app->>cs: setConsumedPosition(commitIdx, fileIdx)
    cs-->>chunker: back-pressure (park/resume)
    chunker-->>stdin: OS pipe blocks on write()
    app->>host: onMoveCommit(delta)
    host->>host: setCursorIndex
    host->>crs: setConsumedCommitIndex
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
src/ui/App.tsx:163-168
**Notes discarded on move — not persisted as described**

The PR description and the workstream checklist both state "Inline notes persist across commit-cursor moves (bucketed by sha)" and "Confirmation prompt for commit-move removed — comments survive the move." Neither is true in this implementation. `confirmCommitMove` calls `review.clearLiveComments()` before `onMoveCommit`, which destroys live comments without saving them. Because `App` remounts on every commit move (`appVersion` bumps in `AppHost`), React resets all review state including notes on mount — there is no sha-bucketed persistence layer that would restore them on return. The confirmation prompt is also still present and still says "will be discarded", directly contradicting the description.

### Issue 2 of 4
src/ui/AppHost.tsx:115-121
The dedup key should fall back to the changeset's own `id` (which already incorporates `anonymous:N` for commits without a sha) rather than `metadata.sha` alone, so distinct anonymous commits aren't collapsed.

```suggestion
      onCommit: (commit) =>
        setCommitBuffer((prev) => {
          // De-dup against late replays — a commit at index N is the same object that
          // was previously emitted. Use the changeset id as the key because metadata.sha
          // is "" for anonymous commits, which would collapse all of them into one.
          if (prev.some((c) => c.changeset.id === commit.changeset.id)) return prev;
          return [...prev, commit];
        }),
```

### Issue 3 of 4
src/core/startup.ts:202-213
The `noReview` variable is only ever `true` when `useFlatStreaming` is `true`, but the streaming block returns early before reaching this legacy path. By the time the code reaches here, `noReview` is always `false`, so `noReview ? true : undefined` is always `undefined`. The assignment is dead code and slightly misleading.

```suggestion
    // Legacy buffered path: review mode, daemon-registered, single-shot parsing.
    const stdinText = await drainLines(sniff.prefixLines, sniff.rest);
    parsedCliInput = {
      kind: "patch",
      file: "-",
      text: stdinText,
      options: {
        ...parsedCliInput.options,
        pager: true,
      },
    };
```

### Issue 4 of 4
src/ui/AppHost.tsx:259-262
The `void createSessionRegistration` at the bottom of the file is described in the comment as a temporary lint-suppression hack "until App.tsx accepts onMoveCommit", but that wiring is now complete. The dead reference can be removed.

Reviews (1): Last reviewed commit: "fix(pager): persist commitDetailsMode ac..." | Re-trigger Greptile

Comment thread src/ui/App.tsx Outdated
Comment on lines +163 to +168
const confirmCommitMove = useCallback(() => {
if (pendingCommitMove === null || !onMoveCommit) return;
review.clearLiveComments();
onMoveCommit(pendingCommitMove);
setPendingCommitMove(null);
}, [pendingCommitMove, onMoveCommit, review]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Notes discarded on move — not persisted as described

The PR description and the workstream checklist both state "Inline notes persist across commit-cursor moves (bucketed by sha)" and "Confirmation prompt for commit-move removed — comments survive the move." Neither is true in this implementation. confirmCommitMove calls review.clearLiveComments() before onMoveCommit, which destroys live comments without saving them. Because App remounts on every commit move (appVersion bumps in AppHost), React resets all review state including notes on mount — there is no sha-bucketed persistence layer that would restore them on return. The confirmation prompt is also still present and still says "will be discarded", directly contradicting the description.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/App.tsx
Line: 163-168

Comment:
**Notes discarded on move — not persisted as described**

The PR description and the workstream checklist both state "Inline notes persist across commit-cursor moves (bucketed by sha)" and "Confirmation prompt for commit-move removed — comments survive the move." Neither is true in this implementation. `confirmCommitMove` calls `review.clearLiveComments()` before `onMoveCommit`, which destroys live comments without saving them. Because `App` remounts on every commit move (`appVersion` bumps in `AppHost`), React resets all review state including notes on mount — there is no sha-bucketed persistence layer that would restore them on return. The confirmation prompt is also still present and still says "will be discarded", directly contradicting the description.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now addressed — the original push to this branch did not include local changes that implemented the persistence work. Subsequent commits land it:

  • liveCommentsByFileId is lifted to AppHost and bucketed by commit sha, with the active slice handed down through App to useReviewController.
  • confirmCommitMove, pendingCommitMove, and the prompt UI are gone. requestMoveCommit now calls onMoveCommit directly.
  • The clearLiveComments() call on move is removed; notes survive the cursor move and are restored when the user navigates back to a previously visited commit.
  • A full reload (onReloadSession) still resets the sha bucket, since a new bootstrap is a different review canvas.

Re-trigger Greptile on the current tip to clear this thread.

Comment thread src/ui/AppHost.tsx
Comment on lines +115 to +121
onCommit: (commit) =>
setCommitBuffer((prev) => {
// De-dup against late replays — a commit at index N is the same object that
// was previously emitted, identified by its sha.
if (prev.some((c) => c.metadata.sha === commit.metadata.sha)) return prev;
return [...prev, commit];
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The dedup key should fall back to the changeset's own id (which already incorporates anonymous:N for commits without a sha) rather than metadata.sha alone, so distinct anonymous commits aren't collapsed.

Suggested change
onCommit: (commit) =>
setCommitBuffer((prev) => {
// De-dup against late replays — a commit at index N is the same object that
// was previously emitted, identified by its sha.
if (prev.some((c) => c.metadata.sha === commit.metadata.sha)) return prev;
return [...prev, commit];
}),
onCommit: (commit) =>
setCommitBuffer((prev) => {
// De-dup against late replays — a commit at index N is the same object that
// was previously emitted. Use the changeset id as the key because metadata.sha
// is "" for anonymous commits, which would collapse all of them into one.
if (prev.some((c) => c.changeset.id === commit.changeset.id)) return prev;
return [...prev, commit];
}),
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/AppHost.tsx
Line: 115-121

Comment:
The dedup key should fall back to the changeset's own `id` (which already incorporates `anonymous:N` for commits without a sha) rather than `metadata.sha` alone, so distinct anonymous commits aren't collapsed.

```suggestion
      onCommit: (commit) =>
        setCommitBuffer((prev) => {
          // De-dup against late replays — a commit at index N is the same object that
          // was previously emitted. Use the changeset id as the key because metadata.sha
          // is "" for anonymous commits, which would collapse all of them into one.
          if (prev.some((c) => c.changeset.id === commit.changeset.id)) return prev;
          return [...prev, commit];
        }),
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/core/startup.ts Outdated
Comment on lines 202 to 213
// Legacy buffered path: review mode, daemon-registered, single-shot parsing.
const stdinText = await drainLines(sniff.prefixLines, sniff.rest);
parsedCliInput = {
kind: "patch",
file: "-",
text: stdinText,
options: {
...parsedCliInput.options,
pager: true,
noReview: noReview ? true : undefined,
},
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The noReview variable is only ever true when useFlatStreaming is true, but the streaming block returns early before reaching this legacy path. By the time the code reaches here, noReview is always false, so noReview ? true : undefined is always undefined. The assignment is dead code and slightly misleading.

Suggested change
// Legacy buffered path: review mode, daemon-registered, single-shot parsing.
const stdinText = await drainLines(sniff.prefixLines, sniff.rest);
parsedCliInput = {
kind: "patch",
file: "-",
text: stdinText,
options: {
...parsedCliInput.options,
pager: true,
noReview: noReview ? true : undefined,
},
};
// Legacy buffered path: review mode, daemon-registered, single-shot parsing.
const stdinText = await drainLines(sniff.prefixLines, sniff.rest);
parsedCliInput = {
kind: "patch",
file: "-",
text: stdinText,
options: {
...parsedCliInput.options,
pager: true,
},
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/startup.ts
Line: 202-213

Comment:
The `noReview` variable is only ever `true` when `useFlatStreaming` is `true`, but the streaming block returns early before reaching this legacy path. By the time the code reaches here, `noReview` is always `false`, so `noReview ? true : undefined` is always `undefined`. The assignment is dead code and slightly misleading.

```suggestion
    // Legacy buffered path: review mode, daemon-registered, single-shot parsing.
    const stdinText = await drainLines(sniff.prefixLines, sniff.rest);
    parsedCliInput = {
      kind: "patch",
      file: "-",
      text: stdinText,
      options: {
        ...parsedCliInput.options,
        pager: true,
      },
    };
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/ui/AppHost.tsx Outdated
Comment on lines +259 to +262
// Suppress unused-import lint until App.tsx accepts onMoveCommit. The wiring below
// fails the typecheck if the prop isn't expected on App, which catches accidental
// reverts.
void createSessionRegistration;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The void createSessionRegistration at the bottom of the file is described in the comment as a temporary lint-suppression hack "until App.tsx accepts onMoveCommit", but that wiring is now complete. The dead reference can be removed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/AppHost.tsx
Line: 259-262

Comment:
The `void createSessionRegistration` at the bottom of the file is described in the comment as a temporary lint-suppression hack "until App.tsx accepts onMoveCommit", but that wiring is now complete. The dead reference can be removed.

How can I resolve this? If you propose a fix, please make it concise.

bezhermoso and others added 25 commits May 8, 2026 11:15
The pager sniffer and the patch normalizer carried identical ANSI strip
regexes. The streaming pipeline wants to call this per line without going
through either of those modules, so move the regexes into a shared helper
both can import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two foundation pieces for the streaming pager pipeline. Neither is wired
into startup yet — that lands in the next commit so the change is easy
to audit on its own.

stdinLines wraps a byte ReadableStream into an async iterator of lines,
decoding UTF-8 in stream mode and carrying partial lines across chunk
boundaries. Mid-codepoint and mid-CRLF splits are covered by tests since
getting them wrong would silently corrupt every diff downstream.

patchSniffer pulls a bounded prefix off a line iterator and decides patch
vs plain using the same regex contract as the legacy looksLikePatchInput
helper. It returns the consumed prefix plus the remaining iterator so the
chunker stage can resume without re-buffering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single readStdinText() with a stdinLines() + sniffPatch()
pipeline. For now, both branches still concatenate before handing off:
patch builds the full text for normalizePatchChangeset, plain returns
the joined text to the existing plain-text pager handler. Behavior is
unchanged; this is the seam Phase 2 needs to attach the per-file
chunker without any further startup changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
State machine that consumes a line iterator and emits one event per file
boundary. The chunk text emitted matches the legacy splitPatchIntoFileChunks
output for the same input — the test ports that helper as a reference and
asserts parity, so per-chunk parsing produces identical results without
ever materializing the whole patch.

Phase 2 only handles file boundaries. The IN_COMMIT_META state and commit
event emission stay reserved for Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the chunker, parses each file event with the existing diff parser,
and pushes batched appends to subscribers. Default batching is one frame
(16 ms) or 32 files, whichever first, which keeps React happy on bursts
while still letting small streams settle in a single render.

Adds optional streaming fields to the shared types (commitId on DiffFile,
isStreaming and commits on Changeset, stream on AppBootstrap) so single-
shot paths are unaffected. ChangesetStreamHandle lives in types so the
streaming module's internals don't need to leak into types.ts.

Exports buildDiffFile from loaders so the streaming path can reuse the
same per-file model the existing loaders produce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When bootstrap.stream is present, AppHost subscribes on mount and grows
changeset.files via setActiveBootstrap appends. We deliberately do NOT
bump appVersion on appends — App stays mounted across the whole stream,
so selection, scroll position, filter input, and pane sizes persist.
That stability is the load-bearing UX claim and the streaming test
exercises it directly: navigate to a file, append more, assert the
selected row hasn't moved.

useReviewController gains a small effect that picks the first file once
the empty initial changeset gets its first entry. From then on, the user
owns selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When pager stdin sniffs as a patch, hand the line iterator to
createChangesetStream and attach it to a synthetic AppBootstrap whose
initial changeset is empty. AppHost takes over from there: files land in
the sidebar and review pane as the chunker emits them.

The streaming path constructs the bootstrap inline rather than going
through loadAppBootstrap because the stream itself is the source of
truth — there is no patch text to load. The watch flag is rejected here
because pager stdin is one-shot and cannot be reopened.

Manually verified end-to-end with `git log -p` from a synthetic 3-commit,
5-file repo: first append in 3 ms, all 15 files received, no crashes.
Pierre stderr noise about commit headers in chunks is the expected
Phase-2 behavior — Phase 3 fixes it via commit-boundary detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the streaming chunker so it recognizes `commit <sha>` headers in
the input and accumulates the verbatim metadata block (commit/Author/Date
lines, blank, indented message body) until the next file boundary. The
captured text rides along on the next file event as `commitHeaderText`,
so the renderer can intersperse plain-text commit headers above each
commit's first file the way `git log -p` displays them in `less`.

This is the smallest data surface that lets the pager view show commit
metadata: a single optional string field on the first file under each
commit. No separate commit type, no parallel commits array, no sidebar
grouping (pager mode hides the sidebar anyway), no commit-navigation
keybindings (visible boundaries from inline blocks make them redundant).
The renderer change to actually display the field is a follow-up.

Removes the dead `onCommit` listener wiring that Phase 2 stubbed in on
the assumption that commit events would be a separate type. Now they
ride on the file events themselves, so the listener interface drops
back to onAppend / onComplete / onError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The streaming task starts before the renderer mounts, so the chunker can
emit `file` events before AppHost has subscribed. Without an internal
replay buffer, those early batches drop on the floor when the listener
set is empty — the PTY pager test caught this: in pager mode the App
rendered an empty changeset and "No files match the current filter."
was the only thing on screen.

Keep an internal replay buffer of all files ever delivered. On subscribe,
replay them so the new listener sees the full history before any future
events. Completion and error states replay too, so a subscriber that
joins after the stream finishes still gets onComplete / onError.

This is what makes the streaming pager actually work end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Setting HUNK_PAGER_STREAM=0 falls back to the pre-streaming path, which
concatenates the full input before parsing. Default is on. Keep this
escape hatch around until the streaming path has been in use for a
release; if a user hits a regression, the legacy path is one env var
away. The two branches share the sniffer up to the patch/plain decision
so the kill switch only affects what happens after detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the unsubscribe cleanup left the streaming task running
detached. Process exit kills it on its own, but during a daemon-driven
session reload AppHost remounts and the orphaned task would keep parsing
input that nothing reads. Calling stream.abort() in the cleanup makes
ownership explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FileSectionLayout grows two new fields, preambleTop and preambleHeight,
so commit metadata blocks can claim vertical space above the file header
without breaking scroll math. Existing geometry consumers (find section
at offset, pinned header ownership, viewport culling) all walk through
sectionTop/headerTop/bodyTop/sectionBottom and stay correct because the
preamble is just additional rows tracked through the same offsets.

Sections without a commit header have preambleHeight 0 and preambleTop
collapses to headerTop, which is byte-identical to the pre-Phase-5 shape
for any caller that doesn't read the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… file

When a DiffFile carries commitHeaderText (set by the streaming pager
chunker on the first file of each commit), DiffSection renders the text
as a dimmed plain-text block between the section separator and the
file header, with one trailing blank row to match the `git log -p` look
in `less`. DiffSectionPlaceholder reserves the same vertical space so
offscreen sections keep stable scroll geometry.

Files that are not the first under a commit (commitHeaderText is
undefined) render unchanged. Files outside a streaming pager input
likewise render unchanged, since commitHeaderText only gets set by
the chunker → changesetStream pipeline.

This is what makes pager mode actually display commit context the way
`git log -p` displays it. Before this commit, commit metadata was
dropped from the rendered output even though Phase 3 had captured it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arks

Streaming alone read as fast as the producer could go and held every
parsed file forever, so memory grew linearly with the input regardless
of how far the user actually scrolled. A memory probe on vim's repo
(~50k files from 10k commits) measured ~786MB RSS pinned in DiffFile
structures.

Phase 6 makes ingestion user-paced. The streaming task awaits a permit
between events; the permit is gated by a hybrid watermark that pauses
when either commits-ahead or files-ahead of the user's selection
exceeds HIGH and resumes only when both drop below LOW. Defaults are
10/5 commits and 2000/1000 files, both configurable via env. Pause
boundaries are between events, so the OS pipe buffer absorbs the
upstream while the producer is parked — git log -p blocks on its own
write() call without using extra memory.

Two data-model additions:
- DiffFile.commitIndex: zero-based index of the file's owning commit,
  set by the streaming pipeline so AppHost can compute the user's
  current commit position in O(1) from the selected file.
- ChangesetStreamHandle.setConsumedPosition(commitIndex, fileIndex):
  back-pressure signal from the consumer. Idempotent and monotonic;
  positions never move backwards in the stream's view.

App.tsx wires this on every selection change, so the producer naturally
pauses while the user reads and resumes when they scroll.

Empirical effect on the same vim full-history input that previously hit
786MB RSS: with the user pinned at commit 0 the held set plateaus at
37 files / ~124MB RSS. Memory at any moment is now bounded by user
scroll position + lookahead window, not by stream size.

Files behind the user's scroll position are still retained for
scroll-back, so total memory still grows linearly if the user reads
every commit. A rear-side drop policy that combines with this
back-pressure to bound peak regardless of scroll behavior remains a
documented follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier streaming phases introduced regressions in the agent review
surface. The session broker captures `info.files` once at registration
and only re-publishes on full reload, but streaming starts with `[]`
and only mutates AppHost-local state on append. Result: `hunk session
list/get/review/context/comment-*` operate on a stale registration
indefinitely. Multi-commit input compounds it — `findDiffFileByPath`
is path-only, so the same path across commits collides on the earliest
revision.

Rather than thread streaming-aware updates through the broker
protocol, this commit reframes the two intents as separate modes.
`git log -p | hunk pager` is timeline scrolling: streaming, no daemon,
no agent surface. `git diff | hunk pager` is per-commit review:
buffered, daemon-registered, full agent surface. They are now
mutually exclusive.

The routing decision lives in startup.ts. Explicit `--no-review` /
`--review` flags win; otherwise the sniffer's `looksLikeCommitLog`
auto-detects log-style input by scanning the prefix for a `commit
<sha>` line at line start. Context lines inside hunks start with
space/+/-, so the detection can't false-positive on diff bodies.

When `noReview` is set, main.tsx skips `hostClient.start()` entirely.
The session never appears in `hunk session list`. The streaming
pipeline runs against a session that has no broker to keep in sync,
which removes the staleness regression by construction.

`HUNK_PAGER_STREAM=0` narrows in scope: it now forces the buffered
path within no-review mode, but no-review mode still skips the
broker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reframes pager-mode auto-detect for log-style input. Instead of the
Phase 7 default of streaming + no-review (timeline scroll), the default
is now commit-by-commit review: each commit becomes its own review
canvas, the daemon stays connected, the agent surface is alive on the
active commit, and Ctrl-N / Ctrl-P navigate forward and backward
through the log. --no-review remains as an explicit opt-out for the
scroll-only case.

Data model. New CommitMetadata, CommitChangeset, and
CommitReviewStreamHandle types. AppBootstrap gains currentCommit and
commitCursor so the chrome can show the active commit and the agent
surface can detect transitions.

Producer. createCommitReviewStream wraps createChangesetStream and
groups files by commit boundaries (commitHeaderText markers), emitting
one CommitChangeset per commit. File watermark is disabled in this
mode — large merge commits render as one large commit. Commits-ahead
back-pressure stays at default 10/5. Backward retention is 100% so
every commit ever consumed stays available for backward navigation.

AppHost. Subscribes to the commit-review stream; accumulates commits;
tracks the cursor. On cursor move, builds a fresh bootstrap from the
active CommitChangeset, calls replaceSession to refresh the daemon's
info.files with the same session id, and bumps appVersion so the App
remounts with a fresh selection / scroll baseline. Re-uses the
existing reload primitive — commit nav is structurally a mini-reload.

Confirmation. When the user attempts to move and liveCommentCount > 0,
the move is held until they press Y. The shortcuts hook intercepts
keys at the top while a confirmation is pending so other actions
can't fire by accident. Enter is treated as cancel so reflexive
presses don't bypass the prompt. Filter/scroll are not triggers —
those regenerate cheaply on the next commit.

Empirical smoke against a 3-commit fixture: top strip shows
"<sha> <subject> · 1 of 3 · Ctrl-N next · Ctrl-P prev"; Ctrl-N
swaps the canvas to the next commit's diffs and updates the strip
to "2 of 3" with the new sha and subject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 introduced commit-by-commit navigation but inherited Phase 7's
pager-mode UI hiding. Sidebar, menu bar, filter input, and the full
keymap were all suppressed because pagerMode was true on every pager-
launched session, including commit-review.

The fix: redefine the App's pagerMode flag so it's false whenever
bootstrap.commitCursor is set. Commit-review sessions now light up the
sidebar (file list, click + keyboard navigation), the menu bar, the
filter (/), the help dialog (?), mode toggles, theme cycling, hunk
navigation with [/], and the full agent-surface shortcut set. The
commit cursor strip and the inline metadata preamble continue to
render above the diff body. Explicit --no-review keeps the bare scroll
chrome; only commit-review now gets the rich one.

Ctrl-N / Ctrl-P were originally bound inside handlePagerShortcut. With
pagerMode now false in commit-review, those handlers don't run, so the
bindings move out to the top-level keyboard dispatcher (after the
confirmation interception, before the per-mode handlers). They stay
no-ops when requestMoveCommit isn't wired, so single-changeset
sessions are unaffected.

Empirical smoke at 240x30 with git log -p --max-count=2 from a 5-file
fixture: top row shows the full menu bar plus the active-commit title;
second row shows the commit cursor strip; below is the standard
sidebar+diff split. Ctrl-N flips to the next commit and the chrome
stays put — only the canvas content swaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a setting + menu item + keyboard shortcut to show/hide the
verbatim commit metadata block (commit/Author/Date/blank/full message
body) above each commit's first file. Subject line stays visible
regardless via the cursor strip in the chrome.

The setting threads through the standard view-preferences pipeline:
CommonOptions.commitDetails (CLI flag), PersistedViewPreferences
.showCommitDetails (config layer), AppBootstrap.initialShowCommitDetails
(bootstrap), then App state. Default true. Only commit-review sessions
expose the menu item and the keybinding — single-changeset reviews
have no commit metadata to toggle.

Threads showCommitDetails through DiffPane → DiffSection /
DiffSectionPlaceholder and into buildFileSectionLayouts /
getCommitPreambleHeight so geometry stays consistent: scroll math,
viewport culling, and pinned-header logic all read the same setting.

Menu item: View → Commit details (hint: c). Only added to the menu
when toggleCommitDetails is provided (i.e., in commit-review). The
appMenus module gains optional showCommitDetails / toggleCommitDetails
fields; the existing buildAppMenus shape is otherwise unchanged.

Keyboard shortcut: 'c'. Bound through useAppKeyboardShortcuts when a
toggleCommitDetails handler is wired, no-op otherwise. Doesn't
collide with any existing binding.

Empirical smoke at 240x30 with git log -p --max-count=2 from a 5-file
fixture: with details ON, the verbatim block (commit sha, Author,
Date, blank, indented body) renders above file1.txt; pressing 'c'
collapses the block so file1.txt sits directly above its first hunk
header. The cursor strip up top still shows "277c968  third commit",
so the subject is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…te menu items

Two related fixes for commit-by-commit review chrome.

Commit metadata position. The verbatim metadata block was rendering
inside the first file's section, which put it visually below the
pinned-header chrome's filename row. The block describes the whole
commit, not file 1, so it should sit above the entire file list.
Move it out of DiffSection into a new chrome row in DiffPane,
positioned above the pinned-header row. CommitHeaderBlock is now a
standalone component shared between DiffPane (the new top-of-pane
position) and DiffSection (still used by the --no-review flat-streaming
path, which renders one block per commit inline as commits scroll past).

Inline blocks in DiffSection are suppressed when DiffPane has a
top-block render (commit-review mode). The flat-streaming path leaves
DiffPane's `commitHeader` prop unset, so DiffSection falls back to
inline. Layout helpers thread `inlineCommitDetails` instead of the
raw `showCommitDetails` flag so geometry and rendering agree.

Navigate menu items. Adds "Previous commit" and "Next commit" items
at the top of the Navigate menu when commit-review is active.
Conditional on `moveToCommit` being wired through buildAppMenus, so
single-changeset reviews don't show them. Hints display Ctrl-P / Ctrl-N
to match the existing key binding. The keyboard handlers are
unchanged — these are menu surface only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four small UX changes from prototype feedback.

- Commit nav keys move from Ctrl-N / Ctrl-P to > and < (next /
  previous). Easier to type, matches the visual direction. Both menu
  hints and the dispatcher updated.
- The cursor strip drops the commit subject and the key hints. It now
  shows just `<short-sha> · N of M` (with `+` suffix on M while still
  streaming). The subject lives in the commit metadata block above the
  diffs; the keys are discoverable from the Navigate menu. The strip
  was getting noisy for what is meant to be a thin progress indicator.
- The "Commit details" toggle no longer hides the entire metadata
  block. Headers (commit / Author / Date / Merge) and the subject line
  always render in commit-review; the toggle now only collapses the
  extended message body. Reasoning: sha + author + when + headline are
  context the reviewer needs at all times to know whose / which commit
  they're looking at; the long body is the optional part.
- Adds collapsedCommitHeader to commitMetadata.ts: walks the verbatim
  header and stops after the first non-blank line past the structured
  headers, which is the subject by Git convention. DiffPane chooses
  full vs collapsed text based on showCommitDetails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rop subject from menu-bar title

Two related chrome refinements.

Tri-state commit details. The c key now cycles between three modes
instead of toggling a boolean:
- full     — verbatim header block (commit/Author/Date/blank/subject + body)
- oneLine  — single-line summary: `commit <sha>  Author: <a>  Date: <YYYY-MM-DD>  <subject>`
- hidden   — block omitted entirely

The View menu item label updates to show the current mode. The
underlying setting becomes commitDetailsMode (replacing the boolean
showCommitDetails) and threads through CommonOptions, the persisted
view preferences (TOML key `commit_details_mode`), AppBootstrap, App
state, and DiffPane. The lower rendering layers (DiffSection,
DiffSectionPlaceholder, fileSectionLayout) keep their boolean
showCommitDetails param — DiffPane derives it from the mode.

oneLineCommitHeader helper added to commitMetadata.ts. Trims the date
to YYYY-MM-DD so the line stays compact regardless of source format.

Menu-bar title. The top-right of MenuBar previously showed the active
commit's subject in commit-review (because Changeset.title was set to
the subject when each CommitChangeset was built). The commit metadata
block above the diffs now shows that information already, so the
title is redundant. Override topTitle in commit-review to read just
"Commit log  +N  -M" (file stats for the active commit). Other modes
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renames the middle commit-details state from "oneLine" to "compact"
and changes its rendering from a single packed line to a two-row
summary:

  commit <shortSha>  Author: <name>  Date: <YYYY-MM-DD>
      <subject>

Subject is indented four spaces to match the convention git uses for
message lines in the verbatim view, so the compact row reads as a
visually consistent collapsed version of the full block. The header
row stays a single line so the structured fields (sha, author, date)
all sit at a glance.

Touches the persisted config key (commit_details_mode now accepts
"compact" instead of "oneLine"), the type literal, the parsed
metadata helper (renamed compactCommitHeader), and the menu label.
The cycle is unchanged in shape: full → compact → hidden → full.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts the commit-details view-mode state from App to AppHost so it
survives the App remount that fires on each commit-cursor move. The
remount stays the right call for selection / scroll / filter (those
don't translate across commits and want a fresh canvas), but the
metadata-visibility preference is a session-level user setting and
shouldn't reset just because the cursor advanced.

Mechanically: AppHost owns the state and the cycle action, passes
both as props to App. App removes its local state and reads from
props. Initial value still flows from bootstrap.initialCommitDetailsMode
so config / CLI defaults are honored at session start, then the user
controls it for the rest of the session.

Verified by smoke against a 2-commit fixture: cycle to compact on
commit 1, press > to advance, the next commit renders compact too;
press < to go back, still compact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lowercase c conflicts with vim-style ]c / [c chord muscle memory: hitting
] (next hunk) followed reflexively by c flipped the commit-details mode.
Move the binding to shift-C so the chord doesn't collide.
Previously the App remount fired by every commit-cursor move dropped any
live comments the user had left on the active commit, and a confirmation
prompt warned about the imminent data loss before each move. With the
sha-keyed store the notes survive — switch to another commit and back
and they reappear — so the prompt is no longer needed.

The store is owned by AppHost (the same level that already kept
commitDetailsMode alive across the remount) and bucketed by commit sha.
useReviewController accepts the slice and writer as optional props so
existing test harnesses still work uncontrolled. A full reload still
resets the store, since a new bootstrap is a different review canvas.
@bezhermoso bezhermoso force-pushed the feat/pager-stream-prototype branch from 1033130 to 62aa674 Compare May 8, 2026 18:17
Anonymous commits — those with no parsed sha — all share metadata.sha = ""
in CommitMetadata. The buffer's late-replay dedup and the swap-tracking ref
were keying off that, so a malformed log with multiple anonymous commits
would collapse the entire anonymous tail into a single buffered entry.

Switch both call sites to changeset.id, which the producer already builds as
`commit:<sha>` for normal commits and `commit:anonymous:N` for anonymous
ones, so distinct entries stay distinct.
bezhermoso added 2 commits May 8, 2026 11:37
Two minor follow-ups from PR review:

- Remove the `void createSessionRegistration` lint-suppression hack and its
  matching import. Phase 8 left it behind to anchor the import while App.tsx
  didn't yet accept onMoveCommit; that wiring is now complete, so the hack is
  dead code.
- Tighten the comment above the legacy-buffered fallback in startup.ts. The
  branch is also reached when HUNK_PAGER_STREAM=0 forces it past --no-review,
  in which case `noReview` is still true and is intentionally forwarded so
  the daemon stays out of it. The original "review mode, daemon-registered"
  framing read like that case was unreachable.
Same anonymous-commit collapse the previous commit fixed for the commit
buffer also applied to the live-comment store. The bucket key was the
active commit's sha (with an empty-string fallback for non-commit-review),
so every commit without a parsed sha shared one bucket — and that bucket
was the same one the non-commit-review canvas used as its sentinel.

Switch the key to the active changeset's id, which is `commit:<sha>` for
normal commits, `commit:anonymous:N` for anonymous ones, and
`changeset:...` shapes elsewhere. Buckets now stay distinct across all
modes, and the DEFAULT_REVIEW_KEY sentinel is no longer needed.

Renamed liveCommentsBySha to liveCommentsByReviewKey to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant