pi-jar 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/democracy.ts CHANGED
@@ -25,7 +25,8 @@ export interface DemocracyResult {
25
25
  shortlist: string[];
26
26
  winner?: string;
27
27
  }
28
- const live = (agent: SubagentReport) => ["queued", "working", "idle", "paused"].includes(agent.state);
28
+ /** Retained voters hold pool slots whether or not their process is currently running. */
29
+ const live = (agent: SubagentReport) => ["queued", "working", "idle", "paused", "hibernated"].includes(agent.state);
29
30
  /** One round never outlives this; late ballots count as failures, never as guesses. */
30
31
  export const BALLOT_TIMEOUT_MS = 10 * 60_000;
31
32
  /** Rationale and failure text returned to the moderator per voter. */
@@ -53,7 +54,8 @@ export function validateDemocracy(request: DemocracyRequest): void {
53
54
  }
54
55
 
55
56
  export function parseBallot(report: SubagentReport, nonce: string, options: readonly DecisionOption[]): Ballot {
56
- if (report.error || report.state !== "idle") throw new Error(report.error ?? "Scout did not finish its ballot");
57
+ // A finished ballot turn is idle, or already hibernated if its process closed before the tally.
58
+ if (report.error || (report.state !== "idle" && report.state !== "hibernated")) throw new Error(report.error ?? "Scout did not finish its ballot");
57
59
  const lines = report.output.split(/\r?\n/).filter((line) => line.startsWith("JAR_BALLOT "));
58
60
  if (lines.length !== 1) throw new Error("Expected exactly one private JAR_BALLOT response");
59
61
  const raw = JSON.parse(lines[0]!.slice(11)) as { round?: unknown; option?: unknown; rationale?: unknown };
@@ -95,8 +97,8 @@ export async function conductDemocracy(controller: DelegateController, request:
95
97
  const fleet = controller.list();
96
98
  for (const id of agents) {
97
99
  const agent = fleet.find((item) => item.id === id);
98
- if (!agent || agent.mode !== "scout" || !["idle", "paused"].includes(agent.state)) {
99
- throw new Error("Only idle/paused fresh-context read-only scouts can be resumed for voting: " + id);
100
+ if (!agent || agent.mode !== "scout" || !["idle", "paused", "hibernated"].includes(agent.state)) {
101
+ throw new Error("Only idle/paused/hibernated fresh-context read-only scouts can be resumed for voting: " + id);
100
102
  }
101
103
  }
102
104
  const newScouts = voters - agents.length;
@@ -0,0 +1,80 @@
1
+ /** Word-level changes inside one replaced line, for intraline emphasis in /diff. */
2
+
3
+ export type Span = readonly [start: number, end: number];
4
+
5
+ /** Token LCS stays tiny: long or wholly rewritten lines are shown without intraline emphasis. */
6
+ const MAX_INLINE_CELLS = 40_000;
7
+ const TOKEN = /\w+|\s+|[^\w\s]/gu;
8
+
9
+ const tokens = (text: string) => Array.from(text.matchAll(TOKEN), match => ({ text: match[0], at: match.index! }));
10
+
11
+ /**
12
+ * Changed `[start, end)` code-unit spans of `before` and `after`. Undefined when the lines are equal,
13
+ * too long to compare cheaply, or share no non-whitespace token (emphasizing everything adds nothing).
14
+ */
15
+ export function inlineChanges(before: string, after: string): { before: Span[]; after: Span[] } | undefined {
16
+ if (before === after) return undefined;
17
+ const a = tokens(before), b = tokens(after);
18
+ let start = 0;
19
+ while (start < a.length && start < b.length && a[start]!.text === b[start]!.text) start++;
20
+ let endA = a.length, endB = b.length;
21
+ while (endA > start && endB > start && a[endA - 1]!.text === b[endB - 1]!.text) { endA--; endB--; }
22
+ const n = endA - start, m = endB - start;
23
+ if (n * m > MAX_INLINE_CELLS) return undefined;
24
+ // lcs[i][j] = LCS length of a[start+i..endA) and b[start+j..endB), flattened.
25
+ const lcs = new Uint16Array((n + 1) * (m + 1));
26
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
27
+ lcs[i * (m + 1) + j] = a[start + i]!.text === b[start + j]!.text ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
28
+ : Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
29
+ }
30
+ const changedA = new Uint8Array(n), changedB = new Uint8Array(m);
31
+ let common = /\S/.test(a.slice(0, start).map(token => token.text).join("") + a.slice(endA).map(token => token.text).join(""));
32
+ for (let i = 0, j = 0; i < n || j < m;) {
33
+ if (i < n && j < m && a[start + i]!.text === b[start + j]!.text) {
34
+ if (/\S/.test(a[start + i]!.text)) common = true;
35
+ i++; j++;
36
+ } else if (j < m && (i >= n || lcs[i * (m + 1) + j + 1]! >= lcs[(i + 1) * (m + 1) + j]!)) changedB[j++] = 1;
37
+ else changedA[i++] = 1;
38
+ }
39
+ if (!common) return undefined;
40
+ return { before: spans(a, start, changedA), after: spans(b, start, changedB) };
41
+ }
42
+
43
+ /** Merge consecutive changed tokens into spans; whitespace between two changes joins them. */
44
+ function spans(list: readonly { text: string; at: number }[], offset: number, changed: Uint8Array): Span[] {
45
+ const result: [number, number][] = [];
46
+ let previous = -1;
47
+ for (let index = 0; index < changed.length; index++) {
48
+ if (!changed[index]) continue;
49
+ const token = list[offset + index]!;
50
+ let joined = previous >= 0;
51
+ for (let gap = previous + 1; joined && gap < index; gap++) if (/\S/.test(list[offset + gap]!.text)) joined = false;
52
+ if (joined) result.at(-1)![1] = token.at + token.text.length;
53
+ else result.push([token.at, token.at + token.text.length]);
54
+ previous = index;
55
+ }
56
+ return result;
57
+ }
58
+
59
+ const SGR = /\x1b\[[0-?]*[ -/]*[@-~]/y;
60
+ const INVERSE_ON = "\x1b[7m";
61
+ const INVERSE_OFF = "\x1b[27m";
62
+
63
+ /**
64
+ * Insert inverse-video markers around `spans` of the visible text inside an SGR-styled string.
65
+ * Offsets count visible code units, so styling (syntax colours) never shifts the emphasis.
66
+ */
67
+ export function emphasize(styled: string, spans: readonly Span[]): string {
68
+ if (!spans.length) return styled;
69
+ let out = "", visible = 0, span = 0, open = false;
70
+ for (let at = 0; at < styled.length;) {
71
+ SGR.lastIndex = at;
72
+ const escape = SGR.exec(styled);
73
+ if (escape) { out += escape[0]; at += escape[0].length; continue; }
74
+ while (span < spans.length && open && visible >= spans[span]![1]) { out += INVERSE_OFF; open = false; span++; }
75
+ if (span < spans.length && !open && visible >= spans[span]![0]) { out += INVERSE_ON; open = true; }
76
+ out += styled[at++];
77
+ visible++;
78
+ }
79
+ return open ? out + INVERSE_OFF : out;
80
+ }
package/src/diff-view.ts CHANGED
@@ -1,6 +1,8 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
- import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow, lineDiff, MAX_REVIEW_DIFF_LINES, type ChangeTracker, type DiffOp, type DiffRow, type FileChange } from "./changes.ts";
1
+ import { getLanguageFromPath, highlightCode, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow, diffSplitHunkOffsets, diffSplitRowCount, diffSplitRowsWindow,
4
+ lineDiff, MAX_REVIEW_DIFF_LINES, type ChangeTracker, type DiffOp, type FileChange } from "./changes.ts";
5
+ import { emphasize, inlineChanges } from "./diff-inline.ts";
4
6
  import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
5
7
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
6
8
 
@@ -48,51 +50,118 @@ export function fullReviewSupported(change: FileChange): boolean {
48
50
  export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, " ")
49
51
  .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, "·");
50
52
 
53
+ /** Optional styling hooks; tests and plain output omit them. */
54
+ export interface DiffPaint {
55
+ /** Syntax colours (foreground SGR only) for one display line, or undefined to use diff colours. */
56
+ syntax?: (text: string) => string | undefined;
57
+ /** SGR background sequence that tints a changed line's text. */
58
+ tint?: (kind: "add" | "remove") => string;
59
+ }
60
+
61
+ /** Context sizes cycled by ←/→ (and [/]); Infinity shows whole files. */
62
+ export const CONTEXT_STEPS = [0, 1, 3, 5, 10, 25, Infinity] as const;
63
+ /** Split columns narrower than this fall back to unified rows. */
64
+ export const MIN_SPLIT_WIDTH = 70;
65
+
66
+ export interface DiffRenderOptions {
67
+ fullReview?: boolean; scroll?: number; rows?: number; unified?: boolean; ops?: readonly DiffOp[]; coarse?: boolean;
68
+ context?: number; wrap?: boolean; paint?: DiffPaint;
69
+ }
70
+ /** Rendered page plus how many logical rows it consumed (wrapping can show fewer than `rows`). */
71
+ export interface DiffWindow { lines: string[]; shown: number; total: number }
72
+
73
+ type Kind = "add" | "remove" | "context";
74
+ const RESET = "\x1b[0m";
75
+ const KIND_COLOR = { add: "success", remove: "error", context: "muted" } as const;
76
+ const SIGN = { add: "+", remove: "-", context: " " } as const;
77
+
51
78
  /** Styled diff rows for one file, with a gutter of old/new line numbers. */
52
- export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string,
53
- options: { fullReview?: boolean; scroll?: number; rows?: number; unified?: boolean; ops?: readonly DiffOp[]; coarse?: boolean } = {}): string[] {
79
+ export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string, options: DiffRenderOptions = {}): string[] {
80
+ return renderDiffWindow(change, width, fg, options).lines;
81
+ }
82
+
83
+ export function renderDiffWindow(change: FileChange, width: number, fg: (color: string, text: string) => string, options: DiffRenderOptions = {}): DiffWindow {
54
84
  const large = fullReviewRequired(change);
55
- if (large && (!options.fullReview || !fullReviewSupported(change))) return [
85
+ if (large && (!options.fullReview || !fullReviewSupported(change))) return { shown: 0, total: 0, lines: [
56
86
  fg("warning", `Summary preview · +${change.added} −${change.removed} · ${(Buffer.byteLength(change.before) + Buffer.byteLength(change.after)) / 1024 | 0} KiB`),
57
87
  fg("dim", fullReviewSupported(change) ? "Press v for a windowed full review, or keep this summary." : "Full review unavailable: preview is bounded to 100,000 combined lines."),
58
88
  fg("dim", "Older/binary/over-1 MiB files are not tracked for safe revert.")
59
- ];
89
+ ] };
60
90
  const exact = options.ops ? change.diff : (change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after)));
61
91
  const ops = options.ops ?? exact ?? coarseLineDiff(change.before, change.after);
62
- const rows = Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW);
92
+ const rows = Math.max(1, Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW));
63
93
  const scroll = Math.max(0, options.scroll ?? 0);
64
- const visible = diffRowsWindow(ops, 3, scroll, rows);
65
- if (!visible.length) return [fg("dim", scroll ? "End of diff." : "No textual changes.")];
94
+ const context = options.context ?? 3;
95
+ const unified = options.unified !== false;
96
+ const wrap = options.wrap === true;
97
+ const paint = options.paint ?? {};
98
+ const total = unified ? diffRowCount(ops, context) : diffSplitRowCount(ops, context);
99
+ const unifiedRows = unified ? diffRowsWindow(ops, context, scroll, rows) : [];
100
+ const splitRows = unified ? [] : diffSplitRowsWindow(ops, context, scroll, rows);
101
+ if (!unifiedRows.length && !splitRows.length) return { lines: [fg("dim", scroll ? "End of diff." : "No textual changes.")], shown: 0, total };
66
102
  const bounded = options.coarse ?? (change.diff === null || (!options.ops && exact === undefined));
67
- const gutter = Math.max(3, String(Math.max(...visible.map((row) => Math.max(row.oldLine ?? 0, row.newLine ?? 0)))).length);
103
+ let widest = 0;
104
+ for (const row of unifiedRows) widest = Math.max(widest, row.oldLine ?? 0, row.newLine ?? 0);
105
+ for (const row of splitRows) if (row.kind === "pair") widest = Math.max(widest, row.left?.line ?? 0, row.right?.line ?? 0);
106
+ const gutter = Math.max(3, String(widest).length);
68
107
  const num = (value?: number) => (value ? String(value) : "").padStart(gutter);
69
- if (options.unified === false) {
108
+
109
+ // Coarse replacement groups pair unrelated lines, so intraline emphasis would mislead there.
110
+ const style = (text: string, kind: Kind, pair: string | undefined) => {
111
+ const display = safeLine(text);
112
+ const styled = paint.syntax?.(display) ?? fg(KIND_COLOR[kind], display);
113
+ if (bounded || kind === "context" || pair === undefined) return styled;
114
+ const changes = kind === "remove" ? inlineChanges(display, safeLine(pair)) : inlineChanges(safeLine(pair), display);
115
+ return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after) : styled;
116
+ };
117
+ // Rail, numbers and sign, then text truncated or wrapped into the cell; continuation lines keep
118
+ // the rail only. Truncation emits full resets, so a tint is re-applied after each one.
119
+ const cell = (kind: Kind, lead: string, text: string, width: number, pair?: string): string[] => {
120
+ const leadWidth = visibleWidth(lead);
121
+ const textWidth = Math.max(1, width - leadWidth - 2);
122
+ const rail = kind === "context" ? " " : fg(KIND_COLOR[kind], "▌");
123
+ const tint = kind === "context" ? undefined : paint.tint?.(kind);
124
+ const styled = style(text, kind, pair);
125
+ return (wrap ? wrapTextWithAnsi(styled, textWidth) : [truncateToWidth(styled, textWidth, "…")]).map((body, index) => {
126
+ const prefix = index ? " ".repeat(leadWidth + 1) : fg("dim", lead) + fg(KIND_COLOR[kind], SIGN[kind]);
127
+ const content = body + " ".repeat(Math.max(0, textWidth - visibleWidth(body)));
128
+ return rail + prefix + (tint ? tint + content.replaceAll(RESET, RESET + tint) : content) + RESET;
129
+ });
130
+ };
131
+ const hunkLine = (text: string, hidden: number | undefined) => truncateToWidth(
132
+ (hidden ? fg("dim", `┄ ${hidden} unchanged line${hidden === 1 ? "" : "s"} ┄ `) : "") + fg("accent", text), width, "…");
133
+
134
+ const blocks: string[][] = unified
135
+ ? unifiedRows.map(row => row.kind === "hunk" ? [hunkLine(row.text, row.hidden)]
136
+ : cell(row.kind === "add" ? "add" : row.kind === "remove" ? "remove" : "context", `${num(row.oldLine)} ${num(row.newLine)} `, row.text, width, row.pair))
137
+ : splitRows.map(row => {
138
+ if (row.kind === "hunk") return [hunkLine(row.text, row.hidden)];
139
+ const column = Math.max(8, Math.floor((width - 3) / 2));
140
+ const replaced = row.left?.changed && row.right?.changed;
141
+ const left = row.left ? cell(row.left.changed ? "remove" : "context", num(row.left.line) + " ", row.left.text, column, replaced ? row.right!.text : undefined) : [];
142
+ const right = row.right ? cell(row.right.changed ? "add" : "context", num(row.right.line) + " ", row.right.text, column, replaced ? row.left!.text : undefined) : [];
143
+ return Array.from({ length: Math.max(left.length, right.length) }, (_, index) =>
144
+ (left[index] ?? " ".repeat(column)) + fg("dim", " │ ") + (right[index] ?? ""));
145
+ });
146
+ const lines: string[] = [];
147
+ if (bounded) lines.push(fg("warning", unified ? "Coarse preview: LCS budget exceeded; full review preserves file order but replacement groups are approximate."
148
+ : "Coarse preview: replacement groups are approximate."));
149
+ if (!unified) {
70
150
  const column = Math.max(8, Math.floor((width - 3) / 2));
71
- return [
72
- truncateToWidth(fg("accent", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"),
73
- ...(bounded ? [fg("warning", "Coarse preview: replacement groups are approximate.")] : []),
74
- ...visible.map(row => {
75
- if (row.kind === "hunk") return fg("accent", row.text);
76
- const oldText = row.kind === "add" ? "" : `${num(row.oldLine)} ${row.kind === "remove" ? "-" : " "}${safeLine(row.text)}`;
77
- const newText = row.kind === "remove" ? "" : `${num(row.newLine)} ${row.kind === "add" ? "+" : " "}${safeLine(row.text)}`;
78
- const left = truncateToWidth(oldText, column, "…", true);
79
- const right = truncateToWidth(newText, column, "…", true);
80
- return (row.kind === "remove" ? fg("error", left) : fg("muted", left)) + fg("dim", " │ ")
81
- + (row.kind === "add" ? fg("success", right) : fg("muted", right));
82
- })
83
- ];
151
+ lines.push(truncateToWidth(fg("accent", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"));
84
152
  }
85
- const rendered = visible.map((row) => {
86
- if (row.kind === "hunk") return fg("accent", row.text);
87
- const text = truncateToWidth(safeLine(row.text), Math.max(1, width - gutter * 2 - 4));
88
- const prefix = fg("dim", `${num(row.oldLine)} ${num(row.newLine)} `);
89
- if (row.kind === "add") return prefix + fg("success", "+" + text);
90
- if (row.kind === "remove") return prefix + fg("error", "-" + text);
91
- return prefix + fg("muted", " " + text);
92
- });
93
- if (bounded) rendered.unshift(fg("warning", "Coarse preview: LCS budget exceeded; full review preserves file order but replacement groups are approximate."));
94
- if (change.added + change.removed > rows) rendered.push(fg("dim", `Windowed preview at row ${scroll + 1} · PgUp/PgDn or j/k`));
95
- return rendered;
153
+ // Fill the viewport with whole logical rows; a first row taller than the viewport is clipped.
154
+ let used = 0, shown = 0;
155
+ for (const block of blocks) {
156
+ if (shown && used + block.length > rows) break;
157
+ const visible = block.slice(0, rows - used);
158
+ lines.push(...visible);
159
+ used += visible.length;
160
+ shown++;
161
+ if (used >= rows) break;
162
+ }
163
+ if (scroll > 0 || scroll + shown < total) lines.push(fg("dim", `Windowed preview · rows ${scroll + 1}–${scroll + shown} of ${total} · PgUp/PgDn`));
164
+ return { lines, shown, total };
96
165
  }
97
166
 
98
167
  /**
@@ -111,15 +180,54 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
111
180
  let searchMode = false;
112
181
  let fullReview = false;
113
182
  let unified = true;
183
+ let contextIndex = CONTEXT_STEPS.indexOf(3);
184
+ let wrap = false;
114
185
  let bodyRows = 0;
186
+ let bodyWidth = 80;
115
187
  let viewportRows = 1;
116
188
  let width = 80;
117
189
  let armed: "one" | "all" | undefined;
118
190
  let message = "";
119
191
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
120
- let cache: { key: string; lines: string[] } | undefined;
121
- let opCache: { key: string; ops: readonly DiffOp[]; hunks: number[]; rows: number; coarse: boolean } | undefined;
192
+ let cache: { key: string; lines: string[]; shown: number } | undefined;
193
+ let opCache: { key: string; ops: readonly DiffOp[]; coarse: boolean } | undefined;
194
+ let paintCache: { path: string; paint: DiffPaint } | undefined;
122
195
  const fg = (color: string, text: string) => theme.fg(color as never, text);
196
+ const context = () => CONTEXT_STEPS[contextIndex]!;
197
+ const contextLabel = () => Number.isFinite(context()) ? `${context()} line${context() === 1 ? "" : "s"}` : "whole file";
198
+ // Split columns need room; narrower panes keep the unified layout instead of unreadable halves.
199
+ const splitMode = () => !unified && bodyWidth >= MIN_SPLIT_WIDTH;
200
+ const rowLayout = () => {
201
+ if (!opCache) return { hunks: [] as number[], rows: 0 };
202
+ return splitMode()
203
+ ? { hunks: diffSplitHunkOffsets(opCache.ops, context()), rows: diffSplitRowCount(opCache.ops, context()) }
204
+ : { hunks: diffHunkOffsets(opCache.ops, context()), rows: diffRowCount(opCache.ops, context()) };
205
+ };
206
+ // Row offsets change with context and split mode; keep the hunk at the top of the viewport.
207
+ const relayout = (change: () => void) => {
208
+ const before = rowLayout().hunks;
209
+ let hunk = -1;
210
+ for (let index = 0; index < before.length && before[index]! <= scroll; index++) hunk = index;
211
+ change();
212
+ scroll = hunk >= 0 ? rowLayout().hunks[hunk] ?? 0 : 0;
213
+ cache = undefined;
214
+ };
215
+ const paintFor = (change: FileChange): DiffPaint => {
216
+ if (paintCache?.path !== change.path) {
217
+ const language = getLanguageFromPath(change.path);
218
+ const bg = (theme as { getBgAnsi?: (color: "toolSuccessBg" | "toolErrorBg") => string }).getBgAnsi?.bind(theme);
219
+ paintCache = { path: change.path, paint: {
220
+ // Per-line highlighting is bounded; very long lines, or a host without Pi's highlighter
221
+ // theme initialised, keep plain diff colours instead of failing the review.
222
+ ...(language ? { syntax: (text: string) => {
223
+ if (text.length > 1000) return undefined;
224
+ try { return highlightCode(text, language)[0]; } catch { return undefined; }
225
+ } } : {}),
226
+ ...(bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {})
227
+ } };
228
+ }
229
+ return paintCache.paint;
230
+ };
123
231
  const refresh = () => {
124
232
  changes = tracker.changes();
125
233
  cache = undefined;
@@ -136,10 +244,24 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
136
244
  const visibleChanges = () => filterChanges(changes, query).files;
137
245
  const currentChange = () => visibleChanges()[selected];
138
246
  const select = (index: number) => { selected = Math.max(0, Math.min(visibleChanges().length - 1, index)); scroll = 0; fullReview = false; cache = undefined; opCache = undefined; };
247
+ const windowAt = (change: FileChange, at: number) => renderDiffWindow(change, bodyWidth, fg, { fullReview, scroll: at, rows: viewportRows,
248
+ ops: opCache?.ops, coarse: opCache?.coarse, unified: !splitMode(), context: context(), wrap, paint: paintFor(change) });
249
+ // Wrapped rows vary in height: the last page starts at the first row whose tail fits the
250
+ // viewport. Measured only when scrolling past the cheap bound, at most one viewport of renders.
251
+ const lastScroll = () => {
252
+ const total = rowLayout().rows;
253
+ let at = Math.max(0, total - viewportRows);
254
+ const current = currentChange();
255
+ if (!wrap || !current) return at;
256
+ while (at < total - 1 && at + windowAt(current, at).shown < total) at++;
257
+ return at;
258
+ };
139
259
  const scrollBody = (delta: number) => {
140
- const max = Math.max(0, (opCache?.rows ?? 0) - viewportRows);
141
- scroll = Math.max(0, Math.min(max, scroll + delta)); cache = undefined;
260
+ const target = scroll + delta;
261
+ const cheap = Math.max(0, rowLayout().rows - viewportRows);
262
+ scroll = Math.max(0, target <= cheap ? target : Math.min(target, lastScroll())); cache = undefined;
142
263
  };
264
+ const page = () => Math.max(1, (wrap ? cache?.shown ?? 1 : viewportRows) - 1);
143
265
  const component = {
144
266
  invalidate() { cache = undefined; },
145
267
  handleInput(data: string) {
@@ -168,7 +290,17 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
168
290
  if (data === "/") { searchMode = true; query = ""; }
169
291
  else if (data === "n" && query) select(selected + 1);
170
292
  else if (data === "N" && query) select(selected - 1);
171
- else if (data === "t") { unified = !unified; cache = undefined; message = unified ? "Unified diff" : "Side-by-side diff"; }
293
+ else if (data === "t") {
294
+ relayout(() => { unified = !unified; });
295
+ message = unified ? "Unified diff" : bodyWidth >= MIN_SPLIT_WIDTH ? "Side-by-side diff" : `Side-by-side needs ${MIN_SPLIT_WIDTH} columns; showing unified`;
296
+ }
297
+ else if (matchesKey(data, Key.left) || data === "[") {
298
+ relayout(() => { contextIndex = Math.max(0, contextIndex - 1); }); message = `Context: ${contextLabel()}`;
299
+ }
300
+ else if (matchesKey(data, Key.right) || data === "]") {
301
+ relayout(() => { contextIndex = Math.min(CONTEXT_STEPS.length - 1, contextIndex + 1); }); message = `Context: ${contextLabel()}`;
302
+ }
303
+ else if (data === "w") { wrap = !wrap; cache = undefined; message = wrap ? "Wrapping long lines" : "Truncating long lines"; }
172
304
  else if (data === "v" && current && fullReviewRequired(current) && fullReviewSupported(current)) {
173
305
  fullReview = !fullReview; scroll = 0; cache = undefined; opCache = undefined; message = fullReview ? "Full windowed review · LCS budget may label a coarse replacement" : "Summary preview";
174
306
  }
@@ -178,16 +310,18 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
178
310
  else if (data === "R" && current) { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
179
311
  else if (matchesKey(data, Key.up) || data === "k") select(selected - 1);
180
312
  else if (matchesKey(data, Key.down) || data === "j") select(selected + 1);
181
- else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(Math.max(1, bodyRows - 2));
182
- else if (matchesKey(data, Key.pageUp)) scrollBody(-Math.max(1, bodyRows - 2));
183
- else if (data === "n" && !query && opCache?.hunks.length) {
184
- scroll = opCache.hunks.find(offset => offset > scroll) ?? opCache.hunks[0]!; cache = undefined;
313
+ else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(page());
314
+ else if (matchesKey(data, Key.pageUp)) scrollBody(-page());
315
+ else if (data === "n" && !query && rowLayout().hunks.length) {
316
+ const hunks = rowLayout().hunks;
317
+ scroll = hunks.find(offset => offset > scroll) ?? hunks[0]!; cache = undefined;
185
318
  }
186
- else if (data === "p" && opCache?.hunks.length) {
187
- scroll = [...opCache.hunks].reverse().find(offset => offset < scroll) ?? opCache.hunks.at(-1)!; cache = undefined;
319
+ else if (data === "p" && rowLayout().hunks.length) {
320
+ const hunks = rowLayout().hunks;
321
+ scroll = [...hunks].reverse().find(offset => offset < scroll) ?? hunks.at(-1)!; cache = undefined;
188
322
  }
189
- else if (data === "g") scrollBody(-Infinity);
190
- else if (data === "G") scrollBody(Infinity);
323
+ else if (data === "g" || matchesKey(data, Key.home)) scrollBody(-Infinity);
324
+ else if (data === "G" || matchesKey(data, Key.end)) scrollBody(Infinity);
191
325
  tui.requestRender();
192
326
  },
193
327
  handleMouse(event: TuiMouseEvent) {
@@ -208,23 +342,24 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
208
342
  width = Math.max(24, available);
209
343
  bodyRows = contentRows(7 + ACTIONS.length, 6);
210
344
  const listWidth = sidebarWidth(width, 20, 36);
211
- const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
345
+ bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
212
346
  const filtered = visibleChanges();
213
347
  const current = filtered[selected];
214
348
  if (!current) return [fg("dim", `No files match /${safeLine(query)}`), fg("dim", "Press / to edit the filter or Esc to clear it.")];
215
349
  const opKey = `${current.path}:${current.before.length}:${current.after.length}`;
216
350
  if (opCache?.key !== opKey && (!fullReviewRequired(current) || fullReview)) {
217
351
  const exact = current.diff === null ? undefined : (current.diff ?? lineDiff(current.before, current.after));
218
- const coarse = exact === undefined;
219
- const ops = exact ?? coarseLineDiff(current.before, current.after);
220
- opCache = { key: opKey, ops, hunks: diffHunkOffsets(ops), rows: diffRowCount(ops), coarse };
352
+ opCache = { key: opKey, ops: exact ?? coarseLineDiff(current.before, current.after), coarse: exact === undefined };
221
353
  }
222
354
  const pathLines = wrapPath(current.path.replace(/[\x00-\x1f\x7f-\x9f]/g, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`), bodyWidth);
223
355
  // Reserve wrapped path, metadata, split/coarse headers and the optional window footer.
224
- viewportRows = Math.max(1, bodyRows - pathLines.length - 1 - (unified ? 0 : 1) - (opCache?.coarse ? 1 : 0) - 1);
225
- scroll = Math.min(scroll, Math.max(0, (opCache?.rows ?? 0) - viewportRows));
226
- const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${unified}:${fullReview}:${scroll}`;
227
- if (cache?.key !== key) cache = { key, lines: renderDiff(current, bodyWidth, fg, { fullReview, scroll, rows: viewportRows, ops: opCache?.ops, coarse: opCache?.coarse, unified }) };
356
+ viewportRows = Math.max(1, bodyRows - pathLines.length - 1 - (splitMode() ? 1 : 0) - (opCache?.coarse ? 1 : 0) - 1);
357
+ scroll = Math.min(scroll, Math.max(0, rowLayout().rows - (wrap ? 1 : viewportRows)));
358
+ const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${splitMode()}:${fullReview}:${scroll}:${contextIndex}:${wrap}`;
359
+ if (cache?.key !== key) {
360
+ const page = windowAt(current, scroll);
361
+ cache = { key, lines: page.lines, shown: page.shown };
362
+ }
228
363
  // The absolute path is intentionally the first row of the right pane, even in split mode.
229
364
  const content = [...pathLines.map(path => fg("accent", path)),
230
365
  fg("dim", `${selected + 1}/${filtered.length} · ${current.status} · +${current.added} −${current.removed}`), ...cache.lines];
@@ -240,8 +375,10 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
240
375
  const title = `± CHANGES · ${filtered.length}/${changes.length} files · +${totals[0]} −${totals[1]}`;
241
376
  const footer = [
242
377
  ...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
243
- fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear` : query ? `/${query} · n/N next match · / new search` : `${SEARCH_HINT} · n/p hunk · t split/unified · v full preview`),
244
- message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn scroll · a accept · r revert · Esc close")
378
+ fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear`
379
+ : query ? `/${query} · n/N next match · / new search`
380
+ : `${SEARCH_HINT} · n/p hunk · t split · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
381
+ message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn/Home/End scroll · a accept · r revert · Esc close")
245
382
  ];
246
383
  const split = splitFrame(theme, width, title, list, content.slice(0, bodyRows), footer, bodyRows, listWidth);
247
384
  layout = split.layout;