Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/review/controller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down
6 changes: 6 additions & 0 deletions src/review/store.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -149,6 +153,7 @@ export function emptyStore(reviewKey, repository) {
rowId: null,
sidebarVisible: null,
sidebarWidth: null,
rowWrap: false,
scope: "uncommitted",
scopeBase: null,
},
Expand Down Expand Up @@ -215,6 +220,7 @@ export function migrateLegacyStore(legacy, reviewKey, repository, model) {
rowId: null,
sidebarVisible: null,
sidebarWidth: null,
rowWrap: false,
scope: "uncommitted",
scopeBase: null,
},
Expand Down
50 changes: 34 additions & 16 deletions src/review/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 },
),
);
Expand All @@ -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 },
),
);
Expand Down
45 changes: 43 additions & 2 deletions test-bun/ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down