pi-diff-review 0.1.19 → 0.1.21

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.
@@ -0,0 +1,171 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { PersistedAsk, ReviewComment } from "./types.ts";
6
+
7
+ const REVIEW_COMMENT_CACHE_ENTRY = "pi-diff-review-cache";
8
+ const REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
9
+ const REVIEW_ASK_CACHE_ENTRY = "pi-diff-review-ask-cache";
10
+
11
+ type ReviewCommentCacheEntry = {
12
+ cacheKey: string;
13
+ comments: ReviewComment[];
14
+ updatedAt: number;
15
+ };
16
+
17
+ type ReviewExplanationCacheEntry = {
18
+ cacheKey: string;
19
+ explanations: Record<string, string>;
20
+ updatedAt: number;
21
+ };
22
+
23
+ type ReviewAskCacheEntry = {
24
+ cacheKey: string;
25
+ ask?: PersistedAsk;
26
+ updatedAt: number;
27
+ };
28
+
29
+ export function getCachedComments(
30
+ ctx: ExtensionCommandContext,
31
+ cacheKey: string,
32
+ ): Map<string, ReviewComment> {
33
+ let latest: ReviewCommentCacheEntry | undefined;
34
+ for (const entry of ctx.sessionManager.getEntries()) {
35
+ if (
36
+ entry.type !== "custom" ||
37
+ entry.customType !== REVIEW_COMMENT_CACHE_ENTRY
38
+ ) {
39
+ continue;
40
+ }
41
+
42
+ const data = entry.data as Partial<ReviewCommentCacheEntry> | undefined;
43
+ if (data?.cacheKey !== cacheKey || !Array.isArray(data.comments)) continue;
44
+
45
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
46
+ latest = {
47
+ cacheKey: data.cacheKey,
48
+ comments: data.comments,
49
+ updatedAt: data.updatedAt ?? 0,
50
+ };
51
+ }
52
+ }
53
+
54
+ return new Map(
55
+ (latest?.comments ?? []).map((comment) => [comment.id, comment]),
56
+ );
57
+ }
58
+
59
+ export function persistCachedComments(
60
+ pi: ExtensionAPI,
61
+ cacheKey: string,
62
+ comments: Iterable<ReviewComment>,
63
+ ): void {
64
+ pi.appendEntry(REVIEW_COMMENT_CACHE_ENTRY, {
65
+ cacheKey,
66
+ comments: [...comments],
67
+ updatedAt: Date.now(),
68
+ } satisfies ReviewCommentCacheEntry);
69
+ }
70
+
71
+ export function getCachedExplanations(
72
+ ctx: ExtensionCommandContext,
73
+ cacheKey: string,
74
+ ): Map<string, string> {
75
+ let latest: ReviewExplanationCacheEntry | undefined;
76
+ for (const entry of ctx.sessionManager.getEntries()) {
77
+ if (
78
+ entry.type !== "custom" ||
79
+ entry.customType !== REVIEW_EXPLANATION_CACHE_ENTRY
80
+ ) {
81
+ continue;
82
+ }
83
+
84
+ const data = entry.data as Partial<ReviewExplanationCacheEntry> | undefined;
85
+ if (
86
+ data?.cacheKey !== cacheKey ||
87
+ !data.explanations ||
88
+ typeof data.explanations !== "object" ||
89
+ Array.isArray(data.explanations)
90
+ ) {
91
+ continue;
92
+ }
93
+
94
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
95
+ latest = {
96
+ cacheKey: data.cacheKey,
97
+ explanations: Object.fromEntries(
98
+ Object.entries(data.explanations).filter(
99
+ (entry): entry is [string, string] =>
100
+ typeof entry[0] === "string" && typeof entry[1] === "string",
101
+ ),
102
+ ),
103
+ updatedAt: data.updatedAt ?? 0,
104
+ };
105
+ }
106
+ }
107
+
108
+ return new Map(Object.entries(latest?.explanations ?? {}));
109
+ }
110
+
111
+ export function persistCachedExplanations(
112
+ pi: ExtensionAPI,
113
+ cacheKey: string,
114
+ explanations: Map<string, string>,
115
+ ): void {
116
+ pi.appendEntry(REVIEW_EXPLANATION_CACHE_ENTRY, {
117
+ cacheKey,
118
+ explanations: Object.fromEntries(explanations),
119
+ updatedAt: Date.now(),
120
+ } satisfies ReviewExplanationCacheEntry);
121
+ }
122
+
123
+ export function getCachedAsk(
124
+ ctx: ExtensionCommandContext,
125
+ cacheKey: string,
126
+ ): PersistedAsk | undefined {
127
+ let latest: ReviewAskCacheEntry | undefined;
128
+ for (const entry of ctx.sessionManager.getEntries()) {
129
+ if (
130
+ entry.type !== "custom" ||
131
+ entry.customType !== REVIEW_ASK_CACHE_ENTRY
132
+ ) {
133
+ continue;
134
+ }
135
+
136
+ const data = entry.data as Partial<ReviewAskCacheEntry> | undefined;
137
+ if (data?.cacheKey !== cacheKey) continue;
138
+
139
+ const ask = data.ask;
140
+ const validAsk =
141
+ ask &&
142
+ typeof ask === "object" &&
143
+ typeof ask.scopeKey === "string" &&
144
+ typeof ask.anchorLineId === "string" &&
145
+ typeof ask.text === "string"
146
+ ? ask
147
+ : undefined;
148
+
149
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
150
+ latest = {
151
+ cacheKey: data.cacheKey,
152
+ ask: validAsk,
153
+ updatedAt: data.updatedAt ?? 0,
154
+ };
155
+ }
156
+ }
157
+
158
+ return latest?.ask;
159
+ }
160
+
161
+ export function persistCachedAsk(
162
+ pi: ExtensionAPI,
163
+ cacheKey: string,
164
+ ask?: PersistedAsk,
165
+ ): void {
166
+ pi.appendEntry(REVIEW_ASK_CACHE_ENTRY, {
167
+ cacheKey,
168
+ ask,
169
+ updatedAt: Date.now(),
170
+ } satisfies ReviewAskCacheEntry);
171
+ }
@@ -41,6 +41,7 @@ import type {
41
41
  SelectionBounds,
42
42
  SplitDiffCell,
43
43
  SplitDiffRow,
44
+ WorkspaceCommentSummary,
44
45
  } from "./types.ts";
45
46
 
46
47
  type InlineBoxPart = "top" | "body" | "bottom";
@@ -122,6 +123,9 @@ export class ReviewComponent {
122
123
  private onExplanationsChanged?: (explanations: Map<string, string>) => void,
123
124
  cachedAsk?: PersistedAsk,
124
125
  private onAskChanged?: (ask?: PersistedAsk) => void,
126
+ private getWorkspaceCommentSummary?: (
127
+ comments: Map<string, ReviewComment>,
128
+ ) => WorkspaceCommentSummary | undefined,
125
129
  ) {
126
130
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
127
131
  this.navigation = new ReviewNavigationState(
@@ -394,11 +398,12 @@ export class ReviewComponent {
394
398
  render(width: number): string[] {
395
399
  const viewportHeight = this.getContentHeight();
396
400
  const selectedLine = this.lines[this.selected];
401
+ const workspaceSummary = this.getWorkspaceCommentSummary?.(this.comments);
397
402
  const output: string[] = [];
398
403
 
399
404
  output.push(
400
405
  this.renderStatusLine(
401
- this.getHeaderText(selectedLine),
406
+ this.getHeaderText(selectedLine, workspaceSummary),
402
407
  "Press h for help",
403
408
  width,
404
409
  ),
@@ -409,15 +414,21 @@ export class ReviewComponent {
409
414
 
410
415
  output.push(
411
416
  truncateToWidth(
412
- this.theme.fg("muted", this.getFooterText(selectedLine)),
417
+ this.theme.fg(
418
+ "muted",
419
+ this.getFooterText(selectedLine, workspaceSummary),
420
+ ),
413
421
  width,
414
422
  ),
415
423
  );
416
424
  return this.helpVisible ? this.renderHelpModal(output, width) : output;
417
425
  }
418
426
 
419
- private getHeaderText(selectedLine?: ReviewLine): string {
420
- const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments`;
427
+ private getHeaderText(
428
+ selectedLine?: ReviewLine,
429
+ workspaceSummary?: WorkspaceCommentSummary,
430
+ ): string {
431
+ const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments${this.formatWorkspaceSummary(workspaceSummary)}`;
421
432
 
422
433
  if (this.editMode) {
423
434
  return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
@@ -1209,7 +1220,10 @@ export class ReviewComponent {
1209
1220
  : position;
1210
1221
  }
1211
1222
 
1212
- private getFooterText(selectedLine?: ReviewLine): string {
1223
+ private getFooterText(
1224
+ selectedLine?: ReviewLine,
1225
+ workspaceSummary?: WorkspaceCommentSummary,
1226
+ ): string {
1213
1227
  if (this.search.mode) {
1214
1228
  return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
1215
1229
  }
@@ -1224,7 +1238,28 @@ export class ReviewComponent {
1224
1238
  const endLine = this.lines[selection.end]!;
1225
1239
  return `Selected ${count} lines: ${formatLocation(startLine)} -> ${formatLocation(endLine)}`;
1226
1240
  }
1227
- return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
1241
+
1242
+ const selectedText = `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
1243
+ const workspaceText = this.formatWorkspaceSummary(workspaceSummary, false);
1244
+ return workspaceText ? `${selectedText} • ${workspaceText}` : selectedText;
1245
+ }
1246
+
1247
+ private formatWorkspaceSummary(
1248
+ summary?: WorkspaceCommentSummary,
1249
+ includeVisible = true,
1250
+ ): string {
1251
+ if (!summary) return "";
1252
+
1253
+ const parts: string[] = [];
1254
+ if (includeVisible && summary.visible > 0)
1255
+ parts.push(`${summary.visible} visible persisted`);
1256
+ if (summary.hiddenInCurrentFiles > 0) {
1257
+ parts.push(`${summary.hiddenInCurrentFiles} hidden in current files`);
1258
+ }
1259
+ if (summary.elsewhere > 0) parts.push(`${summary.elsewhere} elsewhere`);
1260
+ if (summary.stale > 0) parts.push(`${summary.stale} stale`);
1261
+ if (summary.orphaned > 0) parts.push(`${summary.orphaned} orphaned`);
1262
+ return parts.length > 0 ? ` • ${parts.join(" • ")}` : "";
1228
1263
  }
1229
1264
 
1230
1265
  private jumpHunk(direction: 1 | -1): void {
@@ -1340,7 +1375,7 @@ export class ReviewComponent {
1340
1375
  const lineNumber =
1341
1376
  side === "left" ? line.oldLineNumber : line.newLineNumber;
1342
1377
  const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
1343
- let styled = this.renderDiffRowContent(line, prefix);
1378
+ let styled = this.renderDiffRowContent(line, prefix, index);
1344
1379
 
1345
1380
  styled = truncateToWidth(styled, width);
1346
1381
  const selection = this.getSelectionBounds();
@@ -1393,14 +1428,18 @@ export class ReviewComponent {
1393
1428
  return styled;
1394
1429
  }
1395
1430
 
1396
- private renderDiffRowContent(line: ReviewLine, prefix: string): string {
1431
+ private renderDiffRowContent(
1432
+ line: ReviewLine,
1433
+ prefix: string,
1434
+ index: number,
1435
+ ): string {
1397
1436
  switch (line.kind) {
1398
1437
  case "add":
1399
- return `${this.theme.fg("toolDiffAdded", prefix)}${this.getHighlightedDisplayText(line)}`;
1438
+ return `${this.theme.fg("toolDiffAdded", prefix)}${this.getHighlightedDisplayText(line, index)}`;
1400
1439
  case "remove":
1401
- return `${this.theme.fg("toolDiffRemoved", prefix)}${this.getHighlightedDisplayText(line)}`;
1440
+ return `${this.theme.fg("toolDiffRemoved", prefix)}${this.getHighlightedDisplayText(line, index)}`;
1402
1441
  case "context":
1403
- return `${this.theme.fg("toolDiffContext", prefix)}${this.getHighlightedDisplayText(line)}`;
1442
+ return `${this.theme.fg("toolDiffContext", prefix)}${this.getHighlightedDisplayText(line, index)}`;
1404
1443
  case "hunk":
1405
1444
  return this.theme.fg("accent", `${prefix}${this.getDisplayText(line)}`);
1406
1445
  default:
@@ -1408,15 +1447,21 @@ export class ReviewComponent {
1408
1447
  }
1409
1448
  }
1410
1449
 
1411
- private getHighlightedDisplayText(line: ReviewLine): string {
1450
+ private getHighlightedDisplayText(line: ReviewLine, index: number): string {
1412
1451
  const code = this.getDisplayText(line);
1413
1452
  if (!code) return code;
1414
1453
 
1415
1454
  const lang = line.filePath ? getLanguageFromPath(line.filePath) : undefined;
1416
- const cacheKey = `${line.id}\0${lang ?? ""}\0${code}`;
1455
+ const cacheKey = `${line.id}\0${lang ?? ""}\0${this.search.getHighlightCacheKey()}\0${code}`;
1417
1456
  const cached = this.highlightedLineCache.get(cacheKey);
1418
1457
  if (cached != null) return cached;
1419
1458
 
1459
+ const searchHighlighted = this.getSearchHighlightedDisplayText(code, index);
1460
+ if (searchHighlighted) {
1461
+ this.highlightedLineCache.set(cacheKey, searchHighlighted);
1462
+ return searchHighlighted;
1463
+ }
1464
+
1420
1465
  let highlighted = code;
1421
1466
  try {
1422
1467
  highlighted = highlightCode(code, lang)[0] ?? code;
@@ -1428,6 +1473,32 @@ export class ReviewComponent {
1428
1473
  return highlighted;
1429
1474
  }
1430
1475
 
1476
+ private getSearchHighlightedDisplayText(
1477
+ code: string,
1478
+ index: number,
1479
+ ): string | undefined {
1480
+ const matches = this.search.getMatchesForLine(index);
1481
+ if (matches.length === 0) return undefined;
1482
+
1483
+ const activeMatch = this.search.getActiveMatch();
1484
+ let result = "";
1485
+ let cursor = 0;
1486
+ for (const match of matches) {
1487
+ result += code.slice(cursor, match.start);
1488
+ const text = code.slice(match.start, match.end);
1489
+ const isActive =
1490
+ activeMatch?.lineIndex === match.lineIndex &&
1491
+ activeMatch.start === match.start &&
1492
+ activeMatch.end === match.end;
1493
+ result += isActive
1494
+ ? this.theme.bg("selectedBg", this.theme.fg("warning", text))
1495
+ : this.theme.bg("selectedBg", this.theme.fg("accent", text));
1496
+ cursor = match.end;
1497
+ }
1498
+ result += code.slice(cursor);
1499
+ return result;
1500
+ }
1501
+
1431
1502
  private renderDiffLine(
1432
1503
  line: ReviewLine,
1433
1504
  index: number,
@@ -1439,7 +1510,7 @@ export class ReviewComponent {
1439
1510
  const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
1440
1511
  const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
1441
1512
  const prefix = `${commentMark} ${numbers} `;
1442
- let styled = this.renderDiffRowContent(line, prefix);
1513
+ let styled = this.renderDiffRowContent(line, prefix, index);
1443
1514
 
1444
1515
  styled = truncateToWidth(styled, width);
1445
1516
  const inSelection =
@@ -36,16 +36,39 @@ export function formatCommentLocation(comment: ReviewComment): string {
36
36
  export function buildReviewPrompt(
37
37
  comments: ReviewComment[],
38
38
  promptLabel: string,
39
+ ): string {
40
+ return buildCommentPrompt(
41
+ comments,
42
+ `Address this local code review feedback for ${promptLabel}.`,
43
+ "diff",
44
+ );
45
+ }
46
+
47
+ export function buildViewReviewPrompt(
48
+ comments: ReviewComment[],
49
+ promptLabel: string,
50
+ ): string {
51
+ return buildCommentPrompt(
52
+ comments,
53
+ `Address this code review feedback for ${promptLabel}.`,
54
+ "ts",
55
+ );
56
+ }
57
+
58
+ function buildCommentPrompt(
59
+ comments: ReviewComment[],
60
+ intro: string,
61
+ codeFenceLanguage: string,
39
62
  ): string {
40
63
  const body = comments
41
64
  .map((comment) => {
42
65
  const location = formatCommentLocation(comment);
43
66
  const excerpt = comment.lineText.trim()
44
- ? `\n Excerpt:\n\n\`\`\`diff\n${comment.lineText}\n\`\`\``
67
+ ? `\n Excerpt:\n\n\`\`\`${codeFenceLanguage}\n${comment.lineText}\n\`\`\``
45
68
  : "";
46
69
  return `- \`${location}\` — ${comment.text}${excerpt}`;
47
70
  })
48
71
  .join("\n");
49
72
 
50
- return `Address this local code review feedback for ${promptLabel}.\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
73
+ return `${intro}\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
51
74
  }
@@ -1,18 +1,42 @@
1
1
  import { matchesKey } from "@earendil-works/pi-tui";
2
2
  import type { ReviewLine } from "./types.ts";
3
3
 
4
+ function getSearchableLineText(line: ReviewLine): string {
5
+ return (
6
+ line.kind === "add" || line.kind === "remove" || line.kind === "context"
7
+ ? line.text.slice(1)
8
+ : line.text
9
+ ).toLocaleLowerCase();
10
+ }
11
+
4
12
  export type SearchInputResult = {
5
13
  selected?: number;
6
14
  };
7
15
 
16
+ export type SearchMatch = {
17
+ lineIndex: number;
18
+ start: number;
19
+ end: number;
20
+ };
21
+
8
22
  export class ReviewSearchState {
9
23
  mode = false;
10
- query = "";
11
24
  draftQuery = "";
12
25
  message = "";
26
+ private _query = "";
27
+ private activeMatchIndex = -1;
13
28
 
14
29
  constructor(private readonly lines: ReviewLine[]) {}
15
30
 
31
+ get query(): string {
32
+ return this._query;
33
+ }
34
+
35
+ set query(value: string) {
36
+ this._query = value;
37
+ this.activeMatchIndex = -1;
38
+ }
39
+
16
40
  start(): void {
17
41
  this.mode = true;
18
42
  this.draftQuery = this.query;
@@ -63,46 +87,85 @@ export class ReviewSearchState {
63
87
  const query = this.query.trim();
64
88
  if (!query) return {};
65
89
 
66
- const matches = this.getMatchIndexes(query);
90
+ const matches = this.getMatches(query);
67
91
  if (matches.length === 0) {
92
+ this.activeMatchIndex = -1;
68
93
  this.message = `No matches for /${query}`;
69
94
  return {};
70
95
  }
71
96
 
72
97
  this.message = "";
73
- const current =
74
- direction === 1
75
- ? matches.find((index) => index > selected)
76
- : [...matches].reverse().find((index) => index < selected);
77
- return {
78
- selected:
79
- current ??
80
- (direction === 1 ? matches[0]! : matches[matches.length - 1]!),
81
- };
98
+ let nextIndex: number;
99
+ if (this.activeMatchIndex >= 0 && this.activeMatchIndex < matches.length) {
100
+ nextIndex =
101
+ (this.activeMatchIndex + direction + matches.length) % matches.length;
102
+ } else {
103
+ if (direction === 1) {
104
+ nextIndex = matches.findIndex((match) => match.lineIndex >= selected);
105
+ } else {
106
+ nextIndex = -1;
107
+ for (let index = matches.length - 1; index >= 0; index--) {
108
+ if (matches[index]!.lineIndex <= selected) {
109
+ nextIndex = index;
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ if (nextIndex < 0) nextIndex = direction === 1 ? 0 : matches.length - 1;
115
+ }
116
+
117
+ this.activeMatchIndex = nextIndex;
118
+ return { selected: matches[nextIndex]!.lineIndex };
82
119
  }
83
120
 
84
- getStatusText(selected: number): string {
121
+ getStatusText(_selected: number): string {
85
122
  if (this.message) return this.message;
86
123
  if (!this.query) return "";
87
124
 
88
- const matches = this.getMatchIndexes(this.query);
125
+ const matches = this.getMatches(this.query);
89
126
  if (matches.length === 0) return `No matches for /${this.query}`;
90
127
 
91
- const current = matches.findIndex((index) => index === selected);
92
128
  const position =
93
- current >= 0
94
- ? `${current + 1}/${matches.length}`
129
+ this.activeMatchIndex >= 0 && this.activeMatchIndex < matches.length
130
+ ? `${this.activeMatchIndex + 1}/${matches.length}`
95
131
  : `${matches.length} matches`;
96
132
  return `Search /${this.query} • ${position} • n next • N previous • Esc clear search`;
97
133
  }
98
134
 
99
- private getMatchIndexes(query: string): number[] {
135
+ getMatchesForLine(lineIndex: number): SearchMatch[] {
136
+ if (!this.query.trim()) return [];
137
+ return this.getMatches(this.query).filter(
138
+ (match) => match.lineIndex === lineIndex,
139
+ );
140
+ }
141
+
142
+ getActiveMatch(): SearchMatch | undefined {
143
+ if (!this.query.trim()) return undefined;
144
+ const matches = this.getMatches(this.query);
145
+ return this.activeMatchIndex >= 0 && this.activeMatchIndex < matches.length
146
+ ? matches[this.activeMatchIndex]
147
+ : undefined;
148
+ }
149
+
150
+ getHighlightCacheKey(): string {
151
+ const active = this.getActiveMatch();
152
+ return active
153
+ ? `${this.query}\0${active.lineIndex}:${active.start}-${active.end}`
154
+ : this.query;
155
+ }
156
+
157
+ private getMatches(query: string): SearchMatch[] {
100
158
  const needle = query.toLocaleLowerCase();
101
159
  if (!needle) return [];
102
160
 
103
- const matches: number[] = [];
104
- this.lines.forEach((line, index) => {
105
- if (line.text.toLocaleLowerCase().includes(needle)) matches.push(index);
161
+ const matches: SearchMatch[] = [];
162
+ this.lines.forEach((line, lineIndex) => {
163
+ const haystack = getSearchableLineText(line);
164
+ let start = haystack.indexOf(needle);
165
+ while (start >= 0) {
166
+ matches.push({ lineIndex, start, end: start + needle.length });
167
+ start = haystack.indexOf(needle, start + needle.length);
168
+ }
106
169
  });
107
170
  return matches;
108
171
  }
@@ -48,6 +48,14 @@ export type PersistedAsk = {
48
48
  text: string;
49
49
  };
50
50
 
51
+ export type WorkspaceCommentSummary = {
52
+ visible: number;
53
+ hiddenInCurrentFiles: number;
54
+ elsewhere: number;
55
+ stale: number;
56
+ orphaned: number;
57
+ };
58
+
51
59
  export type DiffRenderMode = "unified" | "split";
52
60
 
53
61
  export type SplitDiffCell = {