pi-diff-review 0.1.1 → 0.1.3

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.
@@ -1,1392 +1,6 @@
1
- import { execFileSync } from "node:child_process";
2
- import { parsePatchFiles } from "@pierre/diffs";
3
- import type {
4
- ChangeContent,
5
- ContextContent,
6
- FileDiffMetadata,
7
- Hunk,
8
- } from "@pierre/diffs";
9
- import type {
10
- ExtensionAPI,
11
- ExtensionCommandContext,
12
- Theme,
13
- } from "@mariozechner/pi-coding-agent";
14
- import {
15
- Editor,
16
- matchesKey,
17
- truncateToWidth,
18
- visibleWidth,
19
- wrapTextWithAnsi,
20
- } from "@mariozechner/pi-tui";
21
-
22
- type DiffLineKind = "meta" | "hunk" | "context" | "add" | "remove";
23
-
24
- type ReviewComment = {
25
- id: string;
26
- filePath: string;
27
- text: string;
28
- startLineId: string;
29
- endLineId: string;
30
- startOldLineNumber?: number;
31
- startNewLineNumber?: number;
32
- endOldLineNumber?: number;
33
- endNewLineNumber?: number;
34
- lineText: string;
35
- };
36
-
37
- type ReviewLine = {
38
- id: string;
39
- kind: DiffLineKind;
40
- text: string;
41
- filePath?: string;
42
- oldLineNumber?: number;
43
- newLineNumber?: number;
44
- commentable: boolean;
45
- hunkLabel?: string;
46
- };
47
-
48
- type ReviewResult =
49
- | { action: "submit"; comments: ReviewComment[] }
50
- | { action: "cancel" };
51
-
52
- type SelectionBounds = {
53
- start: number;
54
- end: number;
55
- };
56
-
57
- type DiffSource = {
58
- label: string;
59
- promptLabel: string;
60
- args: string[];
61
- };
62
-
63
- type ReviewLayout = "side-by-side" | "stacked";
64
- type DiffRenderMode = "unified" | "split";
65
-
66
- type SplitDiffCell = {
67
- line: ReviewLine;
68
- index: number;
69
- };
70
-
71
- type SplitDiffRow =
72
- | { kind: "full"; cell: SplitDiffCell }
73
- | { kind: "split"; left?: SplitDiffCell; right?: SplitDiffCell };
74
-
75
- function parseDiffSource(args: string): DiffSource {
76
- const trimmed = args.trim();
77
- if (!trimmed) {
78
- return {
79
- label: "unstaged git diff",
80
- promptLabel: "the current unstaged git diff",
81
- args: [],
82
- };
83
- }
84
-
85
- const gitArgs = trimmed.split(/\s+/).filter(Boolean);
86
- return {
87
- label: `git diff ${trimmed}`,
88
- promptLabel: `\`git diff ${trimmed}\``,
89
- args: gitArgs,
90
- };
91
- }
92
-
93
- function getDiff(cwd: string, source: DiffSource): string {
94
- return execFileSync(
95
- "git",
96
- ["diff", "--no-color", "--unified=3", ...source.args],
97
- {
98
- cwd,
99
- encoding: "utf8",
100
- stdio: ["ignore", "pipe", "pipe"],
101
- },
102
- );
103
- }
104
-
105
- function parseDiff(diffText: string): ReviewLine[] {
106
- try {
107
- const reviewLines = parseDiffWithPierre(diffText);
108
- if (reviewLines.length > 0) return reviewLines;
109
- } catch {
110
- // Fall back to the local parser for any patch formats @pierre/diffs does
111
- // not recognize. The review UI should remain available even if the richer
112
- // parser fails on unusual diff output.
113
- }
114
-
115
- return parseDiffManual(diffText);
116
- }
117
-
118
- function parseDiffWithPierre(diffText: string): ReviewLine[] {
119
- const patches = parsePatchFiles(diffText);
120
- const parsed: ReviewLine[] = [];
121
- let lineIndex = 0;
122
-
123
- const pushLine = (line: Omit<ReviewLine, "id">) => {
124
- parsed.push({ id: `line-${lineIndex++}`, ...line });
125
- };
126
-
127
- for (const patch of patches) {
128
- if (patch.patchMetadata?.trim()) {
129
- for (const line of patch.patchMetadata.trimEnd().split("\n")) {
130
- pushLine({ kind: "meta", text: line, commentable: false });
131
- }
132
- }
133
-
134
- for (const file of patch.files) {
135
- appendPierreFileDiff(file, pushLine);
136
- }
137
- }
138
-
139
- return parsed;
140
- }
141
-
142
- function appendPierreFileDiff(
143
- file: FileDiffMetadata,
144
- pushLine: (line: Omit<ReviewLine, "id">) => void,
145
- ): void {
146
- const previousFile =
147
- file.prevName ?? (file.type === "new" ? undefined : file.name);
148
- const nextFile = file.type === "deleted" ? undefined : file.name;
149
- const displayPreviousFile = previousFile ?? file.name;
150
- const displayNextFile = nextFile ?? file.name;
151
- const currentFile = nextFile ?? previousFile ?? file.name;
152
-
153
- pushLine({
154
- kind: "meta",
155
- text: `diff --git a/${displayPreviousFile} b/${displayNextFile}`,
156
- filePath: currentFile,
157
- commentable: false,
158
- });
159
-
160
- if (file.type === "new" && file.mode) {
161
- pushLine({
162
- kind: "meta",
163
- text: `new file mode ${file.mode}`,
164
- filePath: currentFile,
165
- commentable: false,
166
- });
167
- } else if (file.type === "deleted" && file.mode) {
168
- pushLine({
169
- kind: "meta",
170
- text: `deleted file mode ${file.mode}`,
171
- filePath: currentFile,
172
- commentable: false,
173
- });
174
- } else if (file.prevMode && file.mode && file.prevMode !== file.mode) {
175
- pushLine({
176
- kind: "meta",
177
- text: `old mode ${file.prevMode}`,
178
- filePath: currentFile,
179
- commentable: false,
180
- });
181
- pushLine({
182
- kind: "meta",
183
- text: `new mode ${file.mode}`,
184
- filePath: currentFile,
185
- commentable: false,
186
- });
187
- }
188
-
189
- if (file.prevObjectId && file.newObjectId) {
190
- pushLine({
191
- kind: "meta",
192
- text: `index ${file.prevObjectId}..${file.newObjectId}${file.mode ? ` ${file.mode}` : ""}`,
193
- filePath: currentFile,
194
- commentable: false,
195
- });
196
- }
197
-
198
- if (file.type === "rename-pure" || file.type === "rename-changed") {
199
- if (file.prevName) {
200
- pushLine({
201
- kind: "meta",
202
- text: `rename from ${file.prevName}`,
203
- filePath: currentFile,
204
- commentable: false,
205
- });
206
- }
207
- pushLine({
208
- kind: "meta",
209
- text: `rename to ${file.name}`,
210
- filePath: currentFile,
211
- commentable: false,
212
- });
213
- }
214
-
215
- if (file.hunks.length === 0) return;
216
-
217
- pushLine({
218
- kind: "meta",
219
- text: previousFile ? `--- a/${previousFile}` : "--- /dev/null",
220
- filePath: currentFile,
221
- commentable: false,
222
- });
223
- pushLine({
224
- kind: "meta",
225
- text: nextFile ? `+++ b/${nextFile}` : "+++ /dev/null",
226
- filePath: currentFile,
227
- commentable: false,
228
- });
229
-
230
- for (const hunk of file.hunks) {
231
- appendPierreHunk(file, hunk, currentFile, pushLine);
232
- }
233
- }
234
-
235
- function appendPierreHunk(
236
- file: FileDiffMetadata,
237
- hunk: Hunk,
238
- currentFile: string,
239
- pushLine: (line: Omit<ReviewLine, "id">) => void,
240
- ): void {
241
- const hunkLabel = hunk.hunkSpecs?.trimEnd() ?? "@@";
242
- let oldLine = hunk.deletionStart;
243
- let newLine = hunk.additionStart;
244
- let deletionIndex = hunk.deletionLineIndex;
245
- let additionIndex = hunk.additionLineIndex;
246
-
247
- pushLine({
248
- kind: "hunk",
249
- text: hunkLabel,
250
- filePath: currentFile,
251
- commentable: false,
252
- hunkLabel,
253
- });
254
-
255
- for (const content of hunk.hunkContent) {
256
- if (content.type === "context") {
257
- ({ oldLine, newLine, deletionIndex, additionIndex } = appendPierreContext(
258
- file,
259
- content,
260
- currentFile,
261
- hunkLabel,
262
- oldLine,
263
- newLine,
264
- deletionIndex,
265
- additionIndex,
266
- pushLine,
267
- ));
268
- } else {
269
- ({ oldLine, newLine, deletionIndex, additionIndex } = appendPierreChange(
270
- file,
271
- content,
272
- currentFile,
273
- hunkLabel,
274
- oldLine,
275
- newLine,
276
- deletionIndex,
277
- additionIndex,
278
- pushLine,
279
- ));
280
- }
281
- }
282
- }
283
-
284
- type PierreLineState = {
285
- oldLine: number;
286
- newLine: number;
287
- deletionIndex: number;
288
- additionIndex: number;
289
- };
290
-
291
- function appendPierreContext(
292
- file: FileDiffMetadata,
293
- content: ContextContent,
294
- currentFile: string,
295
- hunkLabel: string,
296
- oldLine: number,
297
- newLine: number,
298
- deletionIndex: number,
299
- additionIndex: number,
300
- pushLine: (line: Omit<ReviewLine, "id">) => void,
301
- ): PierreLineState {
302
- for (let i = 0; i < content.lines; i++) {
303
- const lineText =
304
- file.deletionLines[deletionIndex] ??
305
- file.additionLines[additionIndex] ??
306
- "";
307
- pushLine({
308
- kind: "context",
309
- text: ` ${stripLineEnding(lineText)}`,
310
- filePath: currentFile,
311
- oldLineNumber: oldLine,
312
- newLineNumber: newLine,
313
- commentable: true,
314
- hunkLabel,
315
- });
316
- oldLine++;
317
- newLine++;
318
- deletionIndex++;
319
- additionIndex++;
320
- }
321
-
322
- return { oldLine, newLine, deletionIndex, additionIndex };
323
- }
324
-
325
- function appendPierreChange(
326
- file: FileDiffMetadata,
327
- content: ChangeContent,
328
- currentFile: string,
329
- hunkLabel: string,
330
- oldLine: number,
331
- newLine: number,
332
- deletionIndex: number,
333
- additionIndex: number,
334
- pushLine: (line: Omit<ReviewLine, "id">) => void,
335
- ): PierreLineState {
336
- for (let i = 0; i < content.deletions; i++) {
337
- const lineText = file.deletionLines[deletionIndex] ?? "";
338
- pushLine({
339
- kind: "remove",
340
- text: `-${stripLineEnding(lineText)}`,
341
- filePath: currentFile,
342
- oldLineNumber: oldLine,
343
- commentable: true,
344
- hunkLabel,
345
- });
346
- oldLine++;
347
- deletionIndex++;
348
- }
349
-
350
- for (let i = 0; i < content.additions; i++) {
351
- const lineText = file.additionLines[additionIndex] ?? "";
352
- pushLine({
353
- kind: "add",
354
- text: `+${stripLineEnding(lineText)}`,
355
- filePath: currentFile,
356
- newLineNumber: newLine,
357
- commentable: true,
358
- hunkLabel,
359
- });
360
- newLine++;
361
- additionIndex++;
362
- }
363
-
364
- return { oldLine, newLine, deletionIndex, additionIndex };
365
- }
366
-
367
- function stripLineEnding(text: string): string {
368
- return text.replace(/\r?\n$/, "");
369
- }
370
-
371
- function parseDiffManual(diffText: string): ReviewLine[] {
372
- const lines = diffText.split("\n");
373
- const parsed: ReviewLine[] = [];
374
-
375
- let currentFile: string | undefined;
376
- let previousFile: string | undefined;
377
- let nextFile: string | undefined;
378
- let currentHunk: string | undefined;
379
- let oldLine = 0;
380
- let newLine = 0;
381
- let lineIndex = 0;
382
-
383
- for (const raw of lines) {
384
- if (raw.startsWith("diff --git ")) {
385
- const match = raw.match(/^diff --git a\/(.+?) b\/(.+)$/);
386
- previousFile = match?.[1];
387
- nextFile = match?.[2];
388
- currentFile = nextFile ?? previousFile;
389
- currentHunk = undefined;
390
- parsed.push({
391
- id: `line-${lineIndex++}`,
392
- kind: "meta",
393
- text: raw,
394
- filePath: currentFile,
395
- commentable: false,
396
- });
397
- continue;
398
- }
399
-
400
- if (raw.startsWith("--- ")) {
401
- previousFile =
402
- raw === "--- /dev/null"
403
- ? undefined
404
- : raw.replace(/^--- a\//, "").replace(/^--- /, "");
405
- parsed.push({
406
- id: `line-${lineIndex++}`,
407
- kind: "meta",
408
- text: raw,
409
- filePath: currentFile,
410
- commentable: false,
411
- });
412
- continue;
413
- }
414
-
415
- if (raw.startsWith("+++ ")) {
416
- nextFile =
417
- raw === "+++ /dev/null"
418
- ? undefined
419
- : raw.replace(/^\+\+\+ b\//, "").replace(/^\+\+\+ /, "");
420
- currentFile = nextFile ?? previousFile;
421
- parsed.push({
422
- id: `line-${lineIndex++}`,
423
- kind: "meta",
424
- text: raw,
425
- filePath: currentFile,
426
- commentable: false,
427
- });
428
- continue;
429
- }
430
-
431
- if (raw.startsWith("@@")) {
432
- const match = raw.match(
433
- /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/,
434
- );
435
- if (match) {
436
- oldLine = Number(match[1]);
437
- newLine = Number(match[3]);
438
- }
439
- currentHunk = raw;
440
- parsed.push({
441
- id: `line-${lineIndex++}`,
442
- kind: "hunk",
443
- text: raw,
444
- filePath: currentFile,
445
- commentable: false,
446
- hunkLabel: currentHunk,
447
- });
448
- continue;
449
- }
450
-
451
- if (raw.startsWith("+") && !raw.startsWith("+++")) {
452
- parsed.push({
453
- id: `line-${lineIndex++}`,
454
- kind: "add",
455
- text: raw,
456
- filePath: currentFile,
457
- newLineNumber: newLine,
458
- commentable: Boolean(currentFile),
459
- hunkLabel: currentHunk,
460
- });
461
- newLine++;
462
- continue;
463
- }
464
-
465
- if (raw.startsWith("-") && !raw.startsWith("---")) {
466
- parsed.push({
467
- id: `line-${lineIndex++}`,
468
- kind: "remove",
469
- text: raw,
470
- filePath: currentFile,
471
- oldLineNumber: oldLine,
472
- commentable: Boolean(currentFile),
473
- hunkLabel: currentHunk,
474
- });
475
- oldLine++;
476
- continue;
477
- }
478
-
479
- if (raw.startsWith(" ")) {
480
- parsed.push({
481
- id: `line-${lineIndex++}`,
482
- kind: "context",
483
- text: raw,
484
- filePath: currentFile,
485
- oldLineNumber: oldLine,
486
- newLineNumber: newLine,
487
- commentable: Boolean(currentFile),
488
- hunkLabel: currentHunk,
489
- });
490
- oldLine++;
491
- newLine++;
492
- continue;
493
- }
494
-
495
- parsed.push({
496
- id: `line-${lineIndex++}`,
497
- kind: "meta",
498
- text: raw,
499
- filePath: currentFile,
500
- commentable: false,
501
- });
502
- }
503
-
504
- return parsed;
505
- }
506
-
507
- function formatLocation(line: {
508
- filePath?: string;
509
- oldLineNumber?: number;
510
- newLineNumber?: number;
511
- }): string {
512
- const file = line.filePath ?? "(unknown file)";
513
- if (line.oldLineNumber != null && line.newLineNumber != null) {
514
- if (line.oldLineNumber === line.newLineNumber) {
515
- return `${file}:${line.newLineNumber}`;
516
- }
517
- return `${file}:old:${line.oldLineNumber}/new:${line.newLineNumber}`;
518
- }
519
- if (line.newLineNumber != null) return `${file}:new:${line.newLineNumber}`;
520
- if (line.oldLineNumber != null) return `${file}:old:${line.oldLineNumber}`;
521
- return file;
522
- }
523
-
524
- function formatCommentLocation(comment: ReviewComment): string {
525
- const start = formatLocation({
526
- filePath: comment.filePath,
527
- oldLineNumber: comment.startOldLineNumber,
528
- newLineNumber: comment.startNewLineNumber,
529
- });
530
- const end = formatLocation({
531
- filePath: comment.filePath,
532
- oldLineNumber: comment.endOldLineNumber,
533
- newLineNumber: comment.endNewLineNumber,
534
- });
535
- return start === end ? start : `${start} -> ${end}`;
536
- }
537
-
538
- function buildReviewPrompt(
539
- comments: ReviewComment[],
540
- promptLabel: string,
541
- ): string {
542
- const body = comments
543
- .map((comment) => {
544
- const location = formatCommentLocation(comment);
545
- const excerpt = comment.lineText.trim()
546
- ? `\n Excerpt:\n\n\`\`\`diff\n${comment.lineText}\n\`\`\``
547
- : "";
548
- return `- \`${location}\` — ${comment.text}${excerpt}`;
549
- })
550
- .join("\n");
551
-
552
- return `Address this local code review feedback for ${promptLabel}.\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
553
- }
554
-
555
- function padToWidth(text: string, width: number): string {
556
- const visible = visibleWidth(text);
557
- if (visible >= width) return truncateToWidth(text, width);
558
- return text + " ".repeat(width - visible);
559
- }
560
-
561
- function lineNumberCell(value?: number): string {
562
- return value == null ? " " : String(value).padStart(4, " ");
563
- }
564
-
565
- class ReviewComponent {
566
- private selected = 0;
567
- private scrollTop = 0;
568
- private editMode = false;
569
- private editingCommentKey?: string;
570
- private selectionAnchor?: number;
571
- private layout: ReviewLayout = "side-by-side";
572
- private diffRenderMode: DiffRenderMode = "unified";
573
- private editor: Editor;
574
-
575
- constructor(
576
- private tui: {
577
- requestRender: (full?: boolean) => void;
578
- terminal?: { rows: number; columns: number };
579
- },
580
- private theme: Theme,
581
- private title: string,
582
- private lines: ReviewLine[],
583
- private comments: Map<string, ReviewComment>,
584
- private done: (result: ReviewResult) => void,
585
- ) {
586
- const firstCommentable = this.lines.findIndex((line) => line.commentable);
587
- this.selected = firstCommentable >= 0 ? firstCommentable : 0;
588
-
589
- this.editor = new Editor(tui as never, {
590
- borderColor: (s) => theme.fg("accent", s),
591
- selectList: {
592
- selectedPrefix: (t) => theme.fg("accent", t),
593
- selectedText: (t) => theme.fg("accent", t),
594
- description: (t) => theme.fg("muted", t),
595
- scrollInfo: (t) => theme.fg("dim", t),
596
- noMatch: (t) => theme.fg("warning", t),
597
- },
598
- });
599
-
600
- this.editor.onSubmit = (value) => {
601
- const selection = this.getActiveCommentSelection();
602
- if (!selection) {
603
- this.exitEditMode();
604
- return;
605
- }
606
-
607
- const trimmed = value.trim();
608
- const key = this.getSelectionKey(selection.start, selection.end);
609
- if (!trimmed) {
610
- this.comments.delete(key);
611
- } else {
612
- this.comments.set(
613
- key,
614
- this.buildCommentFromSelection(selection, trimmed),
615
- );
616
- }
617
-
618
- this.exitEditMode();
619
- };
620
- }
621
-
622
- handleInput(data: string): void {
623
- if (this.editMode) {
624
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
625
- this.exitEditMode();
626
- return;
627
- }
628
- this.editor.handleInput(data);
629
- this.tui.requestRender();
630
- return;
631
- }
632
-
633
- if (matchesKey(data, "escape")) {
634
- if (this.hasSelection()) {
635
- this.clearSelection();
636
- } else {
637
- this.done({ action: "cancel" });
638
- }
639
- return;
640
- }
641
- if (data === "q") {
642
- this.done({ action: "cancel" });
643
- return;
644
- }
645
- if (data === "t") {
646
- this.toggleDiffRenderMode();
647
- return;
648
- }
649
- if (matchesKey(data, "ctrl+d")) {
650
- this.move(this.getPageMoveAmount());
651
- return;
652
- }
653
- if (matchesKey(data, "ctrl+u")) {
654
- this.move(-this.getPageMoveAmount());
655
- return;
656
- }
657
- if (data === "j" || matchesKey(data, "down")) {
658
- this.move(1);
659
- return;
660
- }
661
- if (data === "k" || matchesKey(data, "up")) {
662
- this.move(-1);
663
- return;
664
- }
665
- if (data === "J") {
666
- this.extendSelection(1);
667
- return;
668
- }
669
- if (data === "K") {
670
- this.extendSelection(-1);
671
- return;
672
- }
673
- if (data === "n") {
674
- this.jumpHunk(1);
675
- return;
676
- }
677
- if (data === "p") {
678
- this.jumpHunk(-1);
679
- return;
680
- }
681
- if (data === "x") {
682
- this.deleteComment();
683
- return;
684
- }
685
- if (data === "c") {
686
- this.startEditMode();
687
- return;
688
- }
689
- if (data === "R") {
690
- const comments = [...this.comments.values()].sort((a, b) =>
691
- a.id.localeCompare(b.id),
692
- );
693
- if (comments.length === 0) return;
694
- this.done({ action: "submit", comments });
695
- }
696
- }
697
-
698
- render(width: number): string[] {
699
- const viewportHeight = this.getContentHeight();
700
- const selectedLine = this.lines[this.selected];
701
- const output: string[] = [];
702
-
703
- output.push(
704
- truncateToWidth(
705
- this.theme.fg("accent", this.theme.bold(`Local Review: ${this.title}`)),
706
- width,
707
- ),
708
- );
709
- output.push(
710
- truncateToWidth(
711
- this.theme.fg(
712
- "dim",
713
- this.editMode
714
- ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
715
- : this.hasSelection()
716
- ? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • R submit`
717
- : `${this.lines.length} lines • ${this.comments.size} comments • j/k move • ctrl-u/d page • t unified/split • J/K extend • c comment • x delete • n/p hunk • R submit • q quit`,
718
- ),
719
- width,
720
- ),
721
- );
722
- output.push(this.theme.fg("border", "─".repeat(width)));
723
-
724
- if (this.layout === "side-by-side") {
725
- this.ensureScroll(viewportHeight);
726
- output.push(
727
- ...this.renderSideBySide(width, viewportHeight, selectedLine),
728
- );
729
- } else {
730
- const { diffHeight, commentsHeight } =
731
- this.getStackedHeights(viewportHeight);
732
- this.ensureScroll(diffHeight);
733
- output.push(
734
- ...this.renderStacked(width, diffHeight, commentsHeight, selectedLine),
735
- );
736
- }
737
-
738
- output.push(this.theme.fg("border", "─".repeat(width)));
739
- output.push(
740
- truncateToWidth(
741
- this.theme.fg("muted", this.getFooterText(selectedLine)),
742
- width,
743
- ),
744
- );
745
- return output;
746
- }
747
-
748
- private renderSideBySide(
749
- width: number,
750
- height: number,
751
- selectedLine?: ReviewLine,
752
- ): string[] {
753
- const rightWidth = Math.max(28, Math.floor(width * 0.34));
754
- const separatorWidth = 3;
755
- const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
756
- const rightPane = this.renderRightPane(rightWidth, height, selectedLine);
757
- const output: string[] = [];
758
- const diffPane = this.renderDiffRows(leftWidth, height);
759
-
760
- for (let row = 0; row < height; row++) {
761
- const left = diffPane[row] ?? " ".repeat(leftWidth);
762
- const right = rightPane[row] ?? " ".repeat(rightWidth);
763
- const combined = `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`;
764
- output.push(truncateToWidth(combined, width));
765
- }
766
-
767
- return output;
768
- }
769
-
770
- private renderStacked(
771
- width: number,
772
- diffHeight: number,
773
- commentsHeight: number,
774
- selectedLine?: ReviewLine,
775
- ): string[] {
776
- const comments = this.renderRightPane(width, commentsHeight, selectedLine);
777
- return [
778
- ...this.renderDiffRows(width, diffHeight),
779
- this.theme.fg("borderMuted", "─".repeat(width)),
780
- ...Array.from({ length: commentsHeight }, (_, index) =>
781
- padToWidth(truncateToWidth(comments[index] ?? "", width), width),
782
- ),
783
- ];
784
- }
785
-
786
- private renderDiffRows(width: number, height: number): string[] {
787
- return this.diffRenderMode === "split"
788
- ? this.renderSplitDiffRows(width, height)
789
- : this.renderUnifiedDiffRows(width, height);
790
- }
791
-
792
- private renderUnifiedDiffRows(width: number, height: number): string[] {
793
- const output: string[] = [];
794
- const selection = this.getSelectionBounds();
795
-
796
- for (let row = 0; row < height; row++) {
797
- const index = this.scrollTop + row;
798
- const line = this.lines[index];
799
- output.push(
800
- line
801
- ? this.renderDiffLine(
802
- line,
803
- index,
804
- width,
805
- index === this.selected,
806
- selection,
807
- )
808
- : " ".repeat(width),
809
- );
810
- }
811
-
812
- return output;
813
- }
814
-
815
- private renderSplitDiffRows(width: number, height: number): string[] {
816
- const rows = this.buildSplitDiffRows();
817
- const output: string[] = [];
818
- const separatorWidth = 3;
819
- const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
820
- const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
821
-
822
- for (let row = 0; row < height; row++) {
823
- const splitRow = rows[this.scrollTop + row];
824
- if (!splitRow) {
825
- output.push(" ".repeat(width));
826
- continue;
827
- }
828
-
829
- if (splitRow.kind === "full") {
830
- output.push(
831
- this.renderDiffLine(
832
- splitRow.cell.line,
833
- splitRow.cell.index,
834
- width,
835
- splitRow.cell.index === this.selected,
836
- this.getSelectionBounds(),
837
- ),
838
- );
839
- continue;
840
- }
841
-
842
- const left = splitRow.left
843
- ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
844
- : " ".repeat(leftWidth);
845
- const right = splitRow.right
846
- ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
847
- : " ".repeat(rightWidth);
848
- output.push(
849
- truncateToWidth(
850
- `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
851
- width,
852
- ),
853
- );
854
- }
855
-
856
- return output;
857
- }
858
-
859
- private getContentHeight(): number {
860
- const terminalRows = this.tui.terminal?.rows ?? 24;
861
- const headerHeight = 3;
862
- const footerHeight = 2;
863
- return Math.max(6, terminalRows - headerHeight - footerHeight);
864
- }
865
-
866
- private getStackedHeights(viewportHeight: number): {
867
- diffHeight: number;
868
- commentsHeight: number;
869
- } {
870
- const availableForPanes = Math.max(2, viewportHeight - 1);
871
- let diffHeight = Math.max(1, Math.floor(availableForPanes * 0.6));
872
- let commentsHeight = availableForPanes - diffHeight;
873
-
874
- if (commentsHeight < 3 && availableForPanes >= 4) {
875
- commentsHeight = 3;
876
- diffHeight = availableForPanes - commentsHeight;
877
- }
878
-
879
- return { diffHeight, commentsHeight };
880
- }
881
-
882
- invalidate(): void {}
883
-
884
- private move(delta: number): void {
885
- this.selected = Math.max(
886
- 0,
887
- Math.min(this.lines.length - 1, this.selected + delta),
888
- );
889
- this.tui.requestRender();
890
- }
891
-
892
- private toggleLayout(): void {
893
- this.layout = this.layout === "side-by-side" ? "stacked" : "side-by-side";
894
- this.tui.requestRender(true);
895
- }
896
-
897
- private toggleDiffRenderMode(): void {
898
- this.diffRenderMode =
899
- this.diffRenderMode === "unified" ? "split" : "unified";
900
- this.scrollTop = 0;
901
- this.tui.requestRender(true);
902
- }
903
-
904
- private getPageMoveAmount(): number {
905
- const contentHeight = this.getContentHeight();
906
- const diffHeight =
907
- this.layout === "stacked"
908
- ? this.getStackedHeights(contentHeight).diffHeight
909
- : contentHeight;
910
- return Math.max(1, Math.floor(diffHeight / 2));
911
- }
912
-
913
- private extendSelection(delta: number): void {
914
- if (this.selectionAnchor == null) {
915
- this.selectionAnchor = this.selected;
916
- }
917
- this.selected = Math.max(
918
- 0,
919
- Math.min(this.lines.length - 1, this.selected + delta),
920
- );
921
- this.tui.requestRender();
922
- }
923
-
924
- private clearSelection(): void {
925
- this.selectionAnchor = undefined;
926
- this.tui.requestRender();
927
- }
928
-
929
- private hasSelection(): boolean {
930
- return (
931
- this.selectionAnchor != null && this.selectionAnchor !== this.selected
932
- );
933
- }
934
-
935
- private getSelectionBounds(): SelectionBounds | undefined {
936
- if (this.selectionAnchor == null) return undefined;
937
- return {
938
- start: Math.min(this.selectionAnchor, this.selected),
939
- end: Math.max(this.selectionAnchor, this.selected),
940
- };
941
- }
942
-
943
- private getActiveCommentSelection(): SelectionBounds | undefined {
944
- const selection = this.getSelectionBounds();
945
- if (selection) return selection;
946
- const line = this.lines[this.selected];
947
- if (!line?.commentable) return undefined;
948
- return { start: this.selected, end: this.selected };
949
- }
950
-
951
- private getSelectionKey(start: number, end: number): string {
952
- return `${this.lines[start]?.id ?? start}:${this.lines[end]?.id ?? end}`;
953
- }
954
-
955
- private getCommentForSelection(
956
- selection: SelectionBounds | undefined,
957
- ): ReviewComment | undefined {
958
- if (!selection) return undefined;
959
- return this.comments.get(
960
- this.getSelectionKey(selection.start, selection.end),
961
- );
962
- }
963
-
964
- private getCommentKeysForLine(index: number): string[] {
965
- const line = this.lines[index];
966
- if (!line) return [];
967
- return [...this.comments.entries()]
968
- .filter(([, comment]) => {
969
- const start = this.lines.findIndex(
970
- (item) => item.id === comment.startLineId,
971
- );
972
- const end = this.lines.findIndex(
973
- (item) => item.id === comment.endLineId,
974
- );
975
- return start !== -1 && end !== -1 && index >= start && index <= end;
976
- })
977
- .map(([key]) => key);
978
- }
979
-
980
- private buildCommentFromSelection(
981
- selection: SelectionBounds,
982
- text: string,
983
- ): ReviewComment {
984
- const startLine = this.lines[selection.start]!;
985
- const endLine = this.lines[selection.end]!;
986
- const excerpt = this.lines
987
- .slice(selection.start, selection.end + 1)
988
- .map((line) => line.text)
989
- .join("\n");
990
- return {
991
- id: this.getSelectionKey(selection.start, selection.end),
992
- filePath: startLine.filePath ?? endLine.filePath ?? "(unknown file)",
993
- text,
994
- startLineId: startLine.id,
995
- endLineId: endLine.id,
996
- startOldLineNumber: startLine.oldLineNumber,
997
- startNewLineNumber: startLine.newLineNumber,
998
- endOldLineNumber: endLine.oldLineNumber,
999
- endNewLineNumber: endLine.newLineNumber,
1000
- lineText: excerpt,
1001
- };
1002
- }
1003
-
1004
- private getFooterText(selectedLine?: ReviewLine): string {
1005
- const selection = this.getSelectionBounds();
1006
- if (selection) {
1007
- const count = selection.end - selection.start + 1;
1008
- const startLine = this.lines[selection.start]!;
1009
- const endLine = this.lines[selection.end]!;
1010
- return `Selected ${count} lines: ${formatLocation(startLine)} -> ${formatLocation(endLine)}`;
1011
- }
1012
- return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
1013
- }
1014
-
1015
- private jumpHunk(direction: 1 | -1): void {
1016
- let index = this.selected + direction;
1017
- while (index >= 0 && index < this.lines.length) {
1018
- if (this.lines[index]?.kind === "hunk") {
1019
- this.selected = index;
1020
- this.tui.requestRender();
1021
- return;
1022
- }
1023
- index += direction;
1024
- }
1025
- }
1026
-
1027
- private deleteComment(): void {
1028
- const selection = this.getActiveCommentSelection();
1029
- if (!selection) return;
1030
- this.comments.delete(this.getSelectionKey(selection.start, selection.end));
1031
- this.tui.requestRender();
1032
- }
1033
-
1034
- private startEditMode(): void {
1035
- const selection = this.getActiveCommentSelection();
1036
- if (!selection) return;
1037
- const startLine = this.lines[selection.start];
1038
- const endLine = this.lines[selection.end];
1039
- if (!startLine?.commentable || !endLine?.commentable) return;
1040
- if (startLine.filePath !== endLine.filePath) return;
1041
-
1042
- const existing = this.getCommentForSelection(selection);
1043
- this.editMode = true;
1044
- this.editingCommentKey = this.getSelectionKey(
1045
- selection.start,
1046
- selection.end,
1047
- );
1048
- this.editor.setText(existing?.text ?? "");
1049
- this.tui.requestRender(true);
1050
- }
1051
-
1052
- private exitEditMode(): void {
1053
- this.editMode = false;
1054
- this.editingCommentKey = undefined;
1055
- this.editor.setText("");
1056
- this.tui.requestRender(true);
1057
- }
1058
-
1059
- private buildSplitDiffRows(): SplitDiffRow[] {
1060
- const rows: SplitDiffRow[] = [];
1061
- let index = 0;
1062
-
1063
- while (index < this.lines.length) {
1064
- const line = this.lines[index]!;
1065
-
1066
- if (line.kind === "remove" || line.kind === "add") {
1067
- const removals: SplitDiffCell[] = [];
1068
- const additions: SplitDiffCell[] = [];
1069
-
1070
- while (this.lines[index]?.kind === "remove") {
1071
- removals.push({ line: this.lines[index]!, index });
1072
- index++;
1073
- }
1074
- while (this.lines[index]?.kind === "add") {
1075
- additions.push({ line: this.lines[index]!, index });
1076
- index++;
1077
- }
1078
-
1079
- const count = Math.max(removals.length, additions.length);
1080
- for (let offset = 0; offset < count; offset++) {
1081
- rows.push({
1082
- kind: "split",
1083
- left: removals[offset],
1084
- right: additions[offset],
1085
- });
1086
- }
1087
- continue;
1088
- }
1089
-
1090
- if (line.kind === "context") {
1091
- const cell = { line, index };
1092
- rows.push({ kind: "split", left: cell, right: cell });
1093
- } else {
1094
- rows.push({ kind: "full", cell: { line, index } });
1095
- }
1096
- index++;
1097
- }
1098
-
1099
- return rows;
1100
- }
1101
-
1102
- private getSelectedDisplayRow(): number {
1103
- if (this.diffRenderMode === "unified") return this.selected;
1104
- const rows = this.buildSplitDiffRows();
1105
- const row = rows.findIndex((item) =>
1106
- item.kind === "full"
1107
- ? item.cell.index === this.selected
1108
- : item.left?.index === this.selected ||
1109
- item.right?.index === this.selected,
1110
- );
1111
- return row === -1 ? 0 : row;
1112
- }
1113
-
1114
- private getDisplayRowCount(): number {
1115
- return this.diffRenderMode === "unified"
1116
- ? this.lines.length
1117
- : this.buildSplitDiffRows().length;
1118
- }
1119
-
1120
- private renderSplitDiffCell(
1121
- cell: SplitDiffCell,
1122
- width: number,
1123
- side: "left" | "right",
1124
- ): string {
1125
- const { line, index } = cell;
1126
- const hasComment = this.getCommentKeysForLine(index).length > 0;
1127
- const commentMark = hasComment ? this.theme.fg("warning", "●") : " ";
1128
- const lineNumber =
1129
- side === "left" ? line.oldLineNumber : line.newLineNumber;
1130
- const raw = `${commentMark} ${lineNumberCell(lineNumber)} ${line.text}`;
1131
-
1132
- let styled: string;
1133
- switch (line.kind) {
1134
- case "add":
1135
- styled = this.theme.fg("toolDiffAdded", raw);
1136
- break;
1137
- case "remove":
1138
- styled = this.theme.fg("toolDiffRemoved", raw);
1139
- break;
1140
- case "context":
1141
- styled = this.theme.fg("toolDiffContext", raw);
1142
- break;
1143
- default:
1144
- styled = this.theme.fg("muted", raw);
1145
- }
1146
-
1147
- styled = truncateToWidth(styled, width);
1148
- const selection = this.getSelectionBounds();
1149
- const inSelection =
1150
- selection != null && index >= selection.start && index <= selection.end;
1151
- if (index === this.selected || inSelection) {
1152
- return this.theme.bg("selectedBg", padToWidth(styled, width));
1153
- }
1154
- return styled;
1155
- }
1156
-
1157
- private ensureScroll(viewportHeight: number): void {
1158
- const selectedRow = this.getSelectedDisplayRow();
1159
- const rowCount = this.getDisplayRowCount();
1160
-
1161
- if (selectedRow < this.scrollTop) {
1162
- this.scrollTop = selectedRow;
1163
- }
1164
- if (selectedRow >= this.scrollTop + viewportHeight) {
1165
- this.scrollTop = selectedRow - viewportHeight + 1;
1166
- }
1167
- this.scrollTop = Math.max(
1168
- 0,
1169
- Math.min(this.scrollTop, Math.max(0, rowCount - viewportHeight)),
1170
- );
1171
- }
1172
-
1173
- private renderDiffLine(
1174
- line: ReviewLine,
1175
- index: number,
1176
- width: number,
1177
- selected: boolean,
1178
- selection?: SelectionBounds,
1179
- ): string {
1180
- const hasComment = this.getCommentKeysForLine(index).length > 0;
1181
- const commentMark = hasComment ? this.theme.fg("warning", "●") : " ";
1182
- const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
1183
- const raw = `${commentMark} ${numbers} ${line.text}`;
1184
-
1185
- let styled: string;
1186
- switch (line.kind) {
1187
- case "add":
1188
- styled = this.theme.fg("toolDiffAdded", raw);
1189
- break;
1190
- case "remove":
1191
- styled = this.theme.fg("toolDiffRemoved", raw);
1192
- break;
1193
- case "context":
1194
- styled = this.theme.fg("toolDiffContext", raw);
1195
- break;
1196
- case "hunk":
1197
- styled = this.theme.fg("accent", raw);
1198
- break;
1199
- default:
1200
- styled = this.theme.fg("muted", raw);
1201
- }
1202
-
1203
- styled = truncateToWidth(styled, width);
1204
- const inSelection =
1205
- selection != null && index >= selection.start && index <= selection.end;
1206
- if (selected || inSelection) {
1207
- return this.theme.bg("selectedBg", padToWidth(styled, width));
1208
- }
1209
- return styled;
1210
- }
1211
-
1212
- private renderRightPane(
1213
- width: number,
1214
- height: number,
1215
- selectedLine?: ReviewLine,
1216
- ): string[] {
1217
- const lines: string[] = [];
1218
- const title = this.theme.fg("accent", this.theme.bold("Comments"));
1219
- const selection = this.getActiveCommentSelection();
1220
- const currentComment = this.getCommentForSelection(selection);
1221
-
1222
- lines.push(truncateToWidth(title, width));
1223
- lines.push(
1224
- truncateToWidth(
1225
- this.theme.fg(
1226
- "dim",
1227
- selection
1228
- ? this.getFooterText(selectedLine)
1229
- : selectedLine
1230
- ? formatLocation(selectedLine)
1231
- : "No selection",
1232
- ),
1233
- width,
1234
- ),
1235
- );
1236
- lines.push("");
1237
-
1238
- if (!selectedLine) {
1239
- lines.push(
1240
- ...wrapTextWithAnsi(
1241
- this.theme.fg("muted", "No diff lines available."),
1242
- width,
1243
- ),
1244
- );
1245
- return lines.slice(0, height);
1246
- }
1247
-
1248
- if (!selection) {
1249
- lines.push(
1250
- ...wrapTextWithAnsi(
1251
- this.theme.fg(
1252
- "muted",
1253
- "Move to a diff line and press c to add a comment.",
1254
- ),
1255
- width,
1256
- ),
1257
- );
1258
- return lines.slice(0, height);
1259
- }
1260
-
1261
- if (this.editMode && currentComment?.id === this.editingCommentKey) {
1262
- lines.push(
1263
- ...wrapTextWithAnsi(
1264
- this.theme.fg(
1265
- "dim",
1266
- "Editing comment. Enter saves. Esc or Ctrl+C cancels.",
1267
- ),
1268
- width,
1269
- ),
1270
- );
1271
- lines.push("");
1272
- for (const line of this.editor.render(Math.max(10, width))) {
1273
- lines.push(truncateToWidth(line, width));
1274
- }
1275
- } else if (this.editMode && this.editingCommentKey) {
1276
- lines.push(
1277
- ...wrapTextWithAnsi(
1278
- this.theme.fg(
1279
- "dim",
1280
- "Editing comment. Enter saves. Esc or Ctrl+C cancels.",
1281
- ),
1282
- width,
1283
- ),
1284
- );
1285
- lines.push("");
1286
- for (const line of this.editor.render(Math.max(10, width))) {
1287
- lines.push(truncateToWidth(line, width));
1288
- }
1289
- } else if (currentComment) {
1290
- lines.push(
1291
- ...wrapTextWithAnsi(this.theme.fg("text", currentComment.text), width),
1292
- );
1293
- lines.push("");
1294
- lines.push(
1295
- ...wrapTextWithAnsi(
1296
- this.theme.fg("dim", "x deletes this comment • c edits"),
1297
- width,
1298
- ),
1299
- );
1300
- } else {
1301
- lines.push(
1302
- ...wrapTextWithAnsi(
1303
- this.theme.fg(
1304
- "muted",
1305
- this.hasSelection()
1306
- ? "No comment on this range."
1307
- : "No comment on this line.",
1308
- ),
1309
- width,
1310
- ),
1311
- );
1312
- lines.push("");
1313
- lines.push(
1314
- ...wrapTextWithAnsi(
1315
- this.theme.fg(
1316
- "dim",
1317
- this.hasSelection()
1318
- ? "Press c to add a range comment."
1319
- : "Press c to add one. Use J/K to extend a range.",
1320
- ),
1321
- width,
1322
- ),
1323
- );
1324
- }
1325
-
1326
- lines.push("");
1327
- lines.push(
1328
- truncateToWidth(
1329
- this.theme.fg("accent", this.theme.bold("Excerpt")),
1330
- width,
1331
- ),
1332
- );
1333
- const excerpt = this.lines
1334
- .slice(selection.start, selection.end + 1)
1335
- .map((line) => line.text)
1336
- .join("\n");
1337
- lines.push(
1338
- ...wrapTextWithAnsi(
1339
- this.theme.fg("toolDiffContext", excerpt || "(blank line)"),
1340
- width,
1341
- ),
1342
- );
1343
- return lines.slice(0, height);
1344
- }
1345
- }
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerDiffReviewCommand } from "../src/index.ts";
1346
3
 
1347
4
  export default function (pi: ExtensionAPI) {
1348
- pi.registerCommand("diff", {
1349
- description: "Review a git diff in a custom TUI (/diff [git diff args])",
1350
- handler: async (args: string, ctx: ExtensionCommandContext) => {
1351
- const source = parseDiffSource(args);
1352
- let diffText: string;
1353
- try {
1354
- diffText = getDiff(ctx.cwd, source);
1355
- } catch (error) {
1356
- const message = error instanceof Error ? error.message : String(error);
1357
- ctx.ui.notify(`Unable to read ${source.label}: ${message}`, "error");
1358
- return;
1359
- }
1360
-
1361
- if (!diffText.trim()) {
1362
- ctx.ui.notify(`No changes to review for ${source.label}.`, "info");
1363
- return;
1364
- }
1365
-
1366
- const reviewLines = parseDiff(diffText);
1367
- const result = await ctx.ui.custom<ReviewResult>(
1368
- (tui, theme, _keybindings, done) => {
1369
- const comments = new Map<string, ReviewComment>();
1370
- return new ReviewComponent(
1371
- tui,
1372
- theme,
1373
- source.label,
1374
- reviewLines,
1375
- comments,
1376
- done,
1377
- );
1378
- },
1379
- );
1380
-
1381
- if (!result || result.action !== "submit") return;
1382
- if (result.comments.length === 0) {
1383
- ctx.ui.notify("No review comments to send.", "info");
1384
- return;
1385
- }
1386
-
1387
- pi.sendUserMessage(
1388
- buildReviewPrompt(result.comments, source.promptLabel),
1389
- );
1390
- },
1391
- });
5
+ registerDiffReviewCommand(pi);
1392
6
  }