From 70aea70735a757eacd9ee3fe6289b578ffa10d06 Mon Sep 17 00:00:00 2001 From: Aleksandr Kladov Date: Tue, 28 Jul 2026 17:26:56 +0300 Subject: [PATCH] feat: add diff row wrapping --- README.md | 4 +++- agent-guide.md | 4 ++-- src/review/controller.mjs | 8 +++++++ src/review/store.mjs | 6 +++++ src/review/ui.mjs | 50 ++++++++++++++++++++++++++------------- test-bun/ui.test.mjs | 45 +++++++++++++++++++++++++++++++++-- 6 files changed, 96 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e31dc94..882ad8c 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ The review pane uses this stable key contract: | `[` / `]` | Previous/next separated change block | | `{` / `}` | Previous/next file | | `b` | Show/hide the file sidebar | +| `w` | Toggle wrapping of long diff rows | | `v` | Begin/end contiguous range selection | | `s` | Choose old/new target for an unchanged context line | | `c` | Comment on the selected line/range | @@ -171,7 +172,8 @@ divider to resize the file sidebar; its width is restored with the review. Narrow panes initially collapse the file list; `b` shows or hides it without losing the current file and row. Saved comment text is rendered inline beneath its anchored diff range; `n` opens the complete saved-comments list, including -stale notes. +stale notes. Press `w` to wrap long diff rows to the visible diff width; the +choice is restored with the review. The active review scope is shown in the diff title and survives pane hide/show or reopen: diff --git a/agent-guide.md b/agent-guide.md index 33de1f3..692db3b 100644 --- a/agent-guide.md +++ b/agent-guide.md @@ -133,8 +133,8 @@ Fix only problems introduced by this setup. An attached client may need file-changing turn observed from this exact agent. Navigate with `j`/`k`, use `Ctrl+U`/`Ctrl+D` for half-page movement, and switch change blocks or files with `[`/`]` and `{`/`}`; toggle the file sidebar with `b` or drag - its divider to resize it. Letter and bracket shortcuts also work from a - Russian keyboard layout. + its divider to resize it, and toggle long-row wrapping with `w`. Letter and + bracket shortcuts also work from a Russian keyboard layout. 4. Select a line/range with `v`, press `c`, and save with `Ctrl+S`. 5. Press `F6` to hide/restore the same live pane. 6. Press `F7` to insert validated saved comments into the exact source agent. diff --git a/src/review/controller.mjs b/src/review/controller.mjs index ca3d4e4..8a4020c 100644 --- a/src/review/controller.mjs +++ b/src/review/controller.mjs @@ -18,6 +18,7 @@ export class ReviewController { this.preferredSide = "new"; this.sidebarVisible = store.ui?.sidebarVisible ?? null; this.sidebarWidth = store.ui?.sidebarWidth ?? 30; + this.rowWrap = store.ui?.rowWrap ?? false; this.scope = normalizeScope(store.ui?.scope); this.source.setScope?.(this.scope); this.editor = null; @@ -170,6 +171,12 @@ export class ReviewController { if (persist) this.persistUI(); } + toggleRowWrap() { + this.rowWrap = !this.rowWrap; + this.status = `Diff row wrapping ${this.rowWrap ? "enabled" : "disabled"}.`; + this.persistUI(); + } + moveHunk(delta) { const hunks = this.file?.hunks ?? []; if (!hunks.length) return; @@ -308,6 +315,7 @@ export class ReviewController { rowId: this.row?.id ?? null, sidebarVisible: this.sidebarVisible, sidebarWidth: this.sidebarWidth, + rowWrap: this.rowWrap, scope: this.scope, scopeBase: this.source.scopeIdentity?.() ?? null, }; diff --git a/src/review/store.mjs b/src/review/store.mjs index bb7c4aa..959266a 100644 --- a/src/review/store.mjs +++ b/src/review/store.mjs @@ -121,6 +121,10 @@ export function validateStore(document, reviewKey, repository) { document.ui.sidebarWidth <= 80), "invalid sidebar width", ); + assert( + document.ui.rowWrap == null || typeof document.ui.rowWrap === "boolean", + "invalid row-wrap state", + ); assert(REVIEW_SCOPES.includes(document.ui.scope), "invalid review scope"); assert( document.ui.scopeBase == null || @@ -149,6 +153,7 @@ export function emptyStore(reviewKey, repository) { rowId: null, sidebarVisible: null, sidebarWidth: null, + rowWrap: false, scope: "uncommitted", scopeBase: null, }, @@ -215,6 +220,7 @@ export function migrateLegacyStore(legacy, reviewKey, repository, model) { rowId: null, sidebarVisible: null, sidebarWidth: null, + rowWrap: false, scope: "uncommitted", scopeBase: null, }, diff --git a/src/review/ui.mjs b/src/review/ui.mjs index aa5d50a..6f00e5a 100644 --- a/src/review/ui.mjs +++ b/src/review/ui.mjs @@ -277,19 +277,26 @@ export class ReviewUI { ? () => this.confirmDelete() : name === "b" ? () => this.controller.toggleSidebar(defaultSidebarVisible) - : name === "n" - ? () => { - this.showNotes = !this.showNotes; - } - : name === "r" - ? () => this.controller.refresh({ force: true }) - : name === "?" || name === "f1" + : name === "w" ? () => { - this.showHelp = !this.showHelp; + this.controller.toggleRowWrap(); + if (this.controller.rowWrap) { + this.diff.scrollLeft = 0; + } } - : name === "escape" - ? () => this.escape() - : null; + : name === "n" + ? () => { + this.showNotes = !this.showNotes; + } + : name === "r" + ? () => this.controller.refresh({ force: true }) + : name === "?" || name === "f1" + ? () => { + this.showHelp = !this.showHelp; + } + : name === "escape" + ? () => this.escape() + : null; if (!action) return; key.preventDefault(); try { @@ -372,8 +379,9 @@ export class ReviewUI { const sidebarVisible = this.controller.sidebarVisible ?? !narrow; const sidebarWidth = this.clampedSidebarWidth(); - this.diff.title = - ` ${this.controller.source.describeScope?.() ?? this.controller.scope} `; + this.diff.title = ` ${ + this.controller.source.describeScope?.() ?? this.controller.scope + }${this.controller.rowWrap ? " · wrap" : ""} `; this.files.visible = sidebarVisible; this.files.width = sidebarVisible ? (narrow ? "100%" : sidebarWidth) : 0; this.splitter.visible = sidebarVisible && !narrow; @@ -480,6 +488,10 @@ export class ReviewUI { async renderDiff(version) { const file = this.controller.file; + const wrappedRowWidth = Math.max( + 1, + this.diff.viewport.width || this.diff.width, + ); if (!file) { this.diff.add( text( @@ -550,7 +562,9 @@ export class ReviewUI { }); const container = new BoxRenderable(this.ctx, { id: `row:${row.id}`, - height: 1, + height: this.controller.rowWrap ? "auto" : 1, + minHeight: 1, + width: this.controller.rowWrap ? wrappedRowWidth : undefined, flexDirection: "row", backgroundColor: rowBackground(row, selected), onMouseDown: (event) => { @@ -596,7 +610,11 @@ export class ReviewUI { if (version !== this.renderVersion) return; container.add( text(this.ctx, `code:${row.id}`, highlighted, { + height: this.controller.rowWrap ? "auto" : 1, + width: this.controller.rowWrap ? 0 : undefined, flexGrow: 1, + flexShrink: this.controller.rowWrap ? 1 : 0, + wrapMode: this.controller.rowWrap ? "char" : "none", fg: row.kind === "addition" ? COLORS.addition @@ -669,7 +687,7 @@ export class ReviewUI { text( this.ctx, "help", - `1 working · 2 branch · 3 last turn · ↑/k ↓/j rows · Ctrl+U/D half-page · [ ] change blocks · { } files\nb sidebar · v range · s context target · c comment · e edit · n notes · d d delete · r refresh · Esc cancel`, + `1 working · 2 branch · 3 last turn · ↑/k ↓/j rows · Ctrl+U/D half-page · [ ] change blocks · { } files\nb sidebar · w row wrap · v range · s context target · c comment · e edit · n notes · d d delete · r refresh · Esc cancel`, { height: 2 }, ), ); @@ -696,7 +714,7 @@ export class ReviewUI { text( this.ctx, "status-text", - `${this.controller.status} ${commentTarget(this.controller)} · b sidebar · ?/F1 help · r refresh · c comment · n notes`, + `${this.controller.status} ${commentTarget(this.controller)} · b sidebar · w wrap · ?/F1 help · r refresh · c comment · n notes`, { fg: this.controller.status.startsWith("Refresh failed") ? COLORS.warning : COLORS.context }, ), ); diff --git a/test-bun/ui.test.mjs b/test-bun/ui.test.mjs index 0db1c36..994fdc3 100644 --- a/test-bun/ui.test.mjs +++ b/test-bun/ui.test.mjs @@ -6,7 +6,7 @@ import { createTestRenderer } from "@opentui/core/testing"; import { ReviewController } from "../src/review/controller.mjs"; import { createHighlighter } from "../src/review/highlighting.mjs"; import { parseUnifiedDiff } from "../src/review/parse-diff.mjs"; -import { emptyStore } from "../src/review/store.mjs"; +import { emptyStore, readStore } from "../src/review/store.mjs"; import { ReviewUI } from "../src/review/ui.mjs"; const cleanups = []; @@ -46,6 +46,19 @@ function longModel(lineCount = 40) { ); } +function longRowModel() { + return parseUnifiedDiff( + [ + "diff --git a/src/wrap.js b/src/wrap.js", + "--- a/src/wrap.js", + "+++ b/src/wrap.js", + "@@ -0,0 +1 @@", + `+const wrapped = "${"long-content-".repeat(12)}";`, + ].join("\n"), + { generation: 1 }, + ); +} + async function setup( width = 100, height = 24, @@ -71,7 +84,7 @@ async function setup( await ui.render(); await testRenderer.flush(); cleanups.push(async () => testRenderer.renderer.destroy()); - return { ...testRenderer, controller, ui }; + return { ...testRenderer, controller, stateDir, ui }; } function expectSelectedRowVisible(app) { @@ -292,6 +305,34 @@ test("Ctrl+D and Ctrl+U move by half a viewport and keep selection visible", asy expectSelectedRowVisible(app); }); +test("w toggles diff row wrapping and persists the preference", async () => { + const app = await setup(70, 16, undefined, longRowModel()); + const rowId = app.controller.row.id; + let row = app.ui.diff.content.findDescendantById(`row:${rowId}`); + expect(row.height).toBe(1); + expect(app.controller.store.ui.rowWrap).toBe(false); + + app.mockInput.pressKey("w"); + await app.flush(); + row = app.ui.diff.content.findDescendantById(`row:${rowId}`); + expect(app.controller.rowWrap).toBe(true); + expect(app.controller.store.ui.rowWrap).toBe(true); + expect(readStore(app.stateDir, "review-ui", "/repo").ui.rowWrap).toBe(true); + expect(row.height).toBeGreaterThan(1); + expect(row.width).toBeGreaterThanOrEqual(app.ui.diff.viewport.width - 1); + const code = row.findDescendantById(`code:${rowId}`); + expect(code.width).toBeGreaterThan(1); + expect(code.virtualLineCount).toBeGreaterThan(1); + expect(app.ui.diff.title).toContain("wrap"); + + app.mockInput.pressKey("ц"); + await app.flush(); + row = app.ui.diff.content.findDescendantById(`row:${rowId}`); + expect(app.controller.rowWrap).toBe(false); + expect(app.controller.store.ui.rowWrap).toBe(false); + expect(row.height).toBe(1); +}); + describe("Tree-sitter packaged assets", () => { test("all required grammars load offline and produce syntax spans", async () => { const highlighter = await createHighlighter();