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/README.md +15 -12
- package/docs/DIFF-REVIEW.md +5 -2
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +153 -63
- package/package.json +1 -1
- package/src/activity-view.ts +10 -6
- package/src/changes.ts +147 -12
- package/src/delegate-worktree.ts +71 -9
- package/src/delegate.ts +734 -423
- package/src/democracy.ts +6 -4
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +196 -59
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -149
- package/src/footer-settings.ts +3 -29
- package/src/perf.ts +419 -0
- package/src/quota.ts +77 -26
- package/src/settings.ts +2 -12
- package/src/shells.ts +134 -28
package/src/democracy.ts
CHANGED
|
@@ -25,7 +25,8 @@ export interface DemocracyResult {
|
|
|
25
25
|
shortlist: string[];
|
|
26
26
|
winner?: string;
|
|
27
27
|
}
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow,
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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[];
|
|
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
|
|
141
|
-
|
|
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") {
|
|
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(
|
|
182
|
-
else if (matchesKey(data, Key.pageUp)) scrollBody(-
|
|
183
|
-
else if (data === "n" && !query &&
|
|
184
|
-
|
|
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" &&
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 - (
|
|
225
|
-
scroll = Math.min(scroll, Math.max(0, (
|
|
226
|
-
const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${
|
|
227
|
-
if (cache?.key !== key)
|
|
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`
|
|
244
|
-
|
|
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;
|