feat(pager): commit-by-commit review with streaming git log -p#254
feat(pager): commit-by-commit review with streaming git log -p#254bezhermoso wants to merge 28 commits intomodem-dev:mainfrom
git log -p#254Conversation
Greptile SummaryThis PR wires
Confidence Score: 3/5The 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
Sequence DiagramsequenceDiagram
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
Prompt To Fix All With AIFix 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 |
| const confirmCommitMove = useCallback(() => { | ||
| if (pendingCommitMove === null || !onMoveCommit) return; | ||
| review.clearLiveComments(); | ||
| onMoveCommit(pendingCommitMove); | ||
| setPendingCommitMove(null); | ||
| }, [pendingCommitMove, onMoveCommit, review]); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
This is now addressed — the original push to this branch did not include local changes that implemented the persistence work. Subsequent commits land it:
liveCommentsByFileIdis lifted toAppHostand bucketed by commit sha, with the active slice handed down throughApptouseReviewController.confirmCommitMove,pendingCommitMove, and the prompt UI are gone.requestMoveCommitnow callsonMoveCommitdirectly.- 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.
| 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]; | ||
| }), |
There was a problem hiding this 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.
| 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.| // 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, | ||
| }, | ||
| }; |
There was a problem hiding this 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.
| // 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.| // 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; |
There was a problem hiding this 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.
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.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.
1033130 to
62aa674
Compare
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.
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.
Note
This is a working implementation of this proposal: #247
Summary
git log -p | hunk pagerbecomes 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-reviewopt-out preserves the original "timeline scroll" experience for users who only want to read history.What this does
git log -pintohunk pagerand 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.C) cycles "full / compact / hidden" so the strip can collapse to a one-line ribbon or disappear entirely.git log -pblocks on its own pipe write until the user advances.--no-reviewopt-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.HUNK_PAGER_STREAM=0falls 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
Two pager modes
--no-reviewmode (timeline scrolling, no daemon, no agent surface) with auto-detectionCommit-by-commit review
>/<cursor navigation with daemon view refresh per commitCommit-header chrome and persistence
C)Out of scope / follow-ups
hunk session next-commit/prev-commitand the SKILL.md update for agent-driven commit nav are not part of this PR.Test plan
git log -ppipe by content,--no-review,--review, plain-diff fallbackgit log -p(~10k commits), confirm chrome stays put across>/<and memory tracks reading position--no-reviewopt-out on plaingit diffinput