pi-diff-review 0.1.13 → 0.1.14

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 CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  # pi-diff-review
4
4
 
5
- Embedded code reviews directly directly within [pi](https://pi.dev/).
5
+ Embedded code reviews and AI summaries directly within [pi](https://pi.dev/).
6
6
 
7
- <img width="1986" height="1556" alt="pi-diff-review-screenshot(1)" src="https://github.com/user-attachments/assets/3fd00163-5d19-489b-94ed-3d4816c6cad3" />
7
+ <img width="1986" height="1556" alt="pi-diff-review-screenshot" src="https://github.com/user-attachments/assets/5ddd7226-28d4-4617-8c5c-35b4b6af68dc" />
8
8
 
9
9
  ## Install
10
10
 
@@ -65,7 +65,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
65
65
  - `j/k` or arrow keys to move
66
66
  - `g/G` to jump to the top or bottom of the diff
67
67
  - `ctrl-u` / `ctrl-d` to move up/down by half a page
68
- - `t` toggles the comments/explanation sidebar
68
+ - `t` toggles inline comments/explanations
69
69
  - `v` toggles the diff between unified and side-by-side split rendering
70
70
  - `?` toggles an AI-generated explanation for the current hunk
71
71
  - `J/K` to extend a highlighted selection into a comment range
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/prompt.ts CHANGED
@@ -17,7 +17,7 @@ export function formatLocation(line: {
17
17
  return file;
18
18
  }
19
19
 
20
- function formatCommentLocation(comment: ReviewComment): string {
20
+ export function formatCommentLocation(comment: ReviewComment): string {
21
21
  if (comment.global) return "Overall diff";
22
22
 
23
23
  const start = formatLocation({
@@ -2,7 +2,13 @@ import {
2
2
  getLanguageFromPath,
3
3
  highlightCode,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { Editor, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
5
+ import {
6
+ Editor,
7
+ matchesKey,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from "@earendil-works/pi-tui";
6
12
  import type { DiffExplainer } from "./explain.ts";
7
13
  import {
8
14
  GLOBAL_COMMENT_KEY,
@@ -15,12 +21,8 @@ import {
15
21
  ExplanationController,
16
22
  getCurrentHunkScope,
17
23
  } from "./explanation-controller.ts";
18
- import { formatLocation } from "./prompt.ts";
24
+ import { formatCommentLocation, formatLocation } from "./prompt.ts";
19
25
  import { ReviewNavigationState } from "./review-navigation.ts";
20
- import {
21
- renderCommentsPane as renderCommentsPaneContent,
22
- renderExplanationPane as renderExplanationPaneContent,
23
- } from "./review-panes.ts";
24
26
  import { padToWidth, lineNumberCell } from "./render-utils.ts";
25
27
  import { buildSplitDiffRows } from "./split-diff.ts";
26
28
  import type {
@@ -30,28 +32,48 @@ import type {
30
32
  ReviewResult,
31
33
  ReviewTheme,
32
34
  ReviewTui,
33
- RightPaneMode,
34
35
  SelectionBounds,
35
36
  SplitDiffCell,
36
37
  SplitDiffRow,
37
38
  } from "./types.ts";
38
39
 
40
+ type InlineBoxPart = "top" | "body" | "bottom";
41
+ type InlineBoxRowKind = "comment" | "editor" | "explanation";
42
+ type InlineBoxRow = {
43
+ kind: InlineBoxRowKind;
44
+ text: string;
45
+ part: InlineBoxPart;
46
+ };
47
+
48
+ type AnnotatedDiffRow =
49
+ | { kind: "diff"; lineIndex: number }
50
+ | { kind: "split"; splitRowIndex: number }
51
+ | InlineBoxRow;
52
+
39
53
  export class ReviewComponent {
40
54
  private navigation: ReviewNavigationState;
41
55
  private editMode = false;
42
56
  private editingCommentKey?: string;
43
57
 
44
- private showRightPane = true;
45
- private rightPaneMode: RightPaneMode = "comments";
58
+ private inlineAnnotationsVisible = true;
59
+ private visibleExplanationKeys = new Set<string>();
46
60
  private explanationController: ExplanationController;
47
61
  private editor: Editor;
48
62
  private splitRows?: SplitDiffRow[];
49
- private splitRowByLineIndex?: number[];
50
63
  private lineIndexById = new Map<string, number>();
51
64
  private commentLineKeys = new Map<number, string[]>();
52
65
  private commentsRevision = 0;
53
66
  private commentLineKeysRevision = -1;
54
67
  private highlightedLineCache = new Map<string, string>();
68
+ private annotatedRows?: AnnotatedDiffRow[];
69
+ private annotatedRowsWidth = 0;
70
+ private annotatedRowsRevision = -1;
71
+ private annotatedRowsEditMode = false;
72
+ private annotatedRowsEditingCommentKey?: string;
73
+ private annotatedRowsMode?: DiffRenderMode;
74
+ private annotatedRowsInlineAnnotationsVisible = true;
75
+ private annotatedRowsVisibleExplanationCount = 0;
76
+ private annotatedRowByLineIndex?: number[];
55
77
 
56
78
  constructor(
57
79
  private tui: ReviewTui,
@@ -120,6 +142,7 @@ export class ReviewComponent {
120
142
  this.markCommentsChanged();
121
143
  }
122
144
 
145
+ this.navigation.clearSelection();
123
146
  this.exitEditMode();
124
147
  };
125
148
  }
@@ -151,6 +174,7 @@ export class ReviewComponent {
151
174
  return;
152
175
  }
153
176
  this.editor.handleInput(data);
177
+ this.invalidateAnnotatedRows();
154
178
  this.tui.requestRender();
155
179
  return;
156
180
  }
@@ -168,7 +192,7 @@ export class ReviewComponent {
168
192
  return;
169
193
  }
170
194
  if (data === "t") {
171
- this.toggleRightPane();
195
+ this.toggleInlineAnnotations();
172
196
  return;
173
197
  }
174
198
  if (data === "v") {
@@ -250,23 +274,17 @@ export class ReviewComponent {
250
274
  this.theme.fg(
251
275
  "dim",
252
276
  this.editMode
253
- ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
277
+ ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
254
278
  : this.hasSelection()
255
- ? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
256
- : `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • ${this.showRightPane ? "sidebar shown" : "sidebar hidden"} • j/k move • g/G top/bottom • ctrl-u/d page • t sidebar • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
279
+ ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
280
+ : `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • inline ${this.inlineAnnotationsVisible ? "shown" : "hidden"} • j/k move • g/G top/bottom • ctrl-u/d page • t annotations • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
257
281
  ),
258
282
  width,
259
283
  ),
260
284
  );
261
- if (!this.showRightPane) {
262
- this.ensureScroll(viewportHeight);
263
- output.push(...this.renderFullWidthDiffRows(width, viewportHeight));
264
- } else {
265
- this.ensureScroll(viewportHeight);
266
- output.push(
267
- ...this.renderSideBySide(width, viewportHeight, selectedLine),
268
- );
269
- }
285
+
286
+ this.ensureScroll(viewportHeight, width);
287
+ output.push(...this.renderAnnotatedDiffRows(width, viewportHeight));
270
288
 
271
289
  output.push(
272
290
  truncateToWidth(
@@ -277,105 +295,415 @@ export class ReviewComponent {
277
295
  return output;
278
296
  }
279
297
 
280
- private renderSideBySide(
281
- width: number,
282
- height: number,
283
- selectedLine?: ReviewLine,
284
- ): string[] {
285
- const rightWidth = Math.max(28, Math.floor(width * 0.34));
286
- const separatorWidth = 3;
287
- const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
288
- const rightPane = this.renderRightPane(rightWidth, height, selectedLine);
298
+ private renderAnnotatedDiffRows(width: number, height: number): string[] {
299
+ const rows = this.getAnnotatedRows(width);
289
300
  const output: string[] = [];
290
- const diffPane = this.renderDiffRows(leftWidth, height);
291
301
 
292
302
  for (let row = 0; row < height; row++) {
293
- const left = diffPane[row] ?? " ".repeat(leftWidth);
294
- const right = rightPane[row] ?? " ".repeat(rightWidth);
295
- const combined = `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`;
296
- output.push(truncateToWidth(combined, width));
303
+ const annotated = rows[this.scrollTop + row];
304
+ if (!annotated) {
305
+ output.push(" ".repeat(width));
306
+ continue;
307
+ }
308
+
309
+ if (annotated.kind === "diff") {
310
+ const index = annotated.lineIndex;
311
+ const line = this.lines[index]!;
312
+ output.push(
313
+ this.renderDiffLine(
314
+ line,
315
+ index,
316
+ width,
317
+ index === this.selected,
318
+ this.getSelectionBounds(),
319
+ ),
320
+ );
321
+ continue;
322
+ }
323
+
324
+ if (annotated.kind === "split") {
325
+ output.push(this.renderSplitDiffRowAt(annotated.splitRowIndex, width));
326
+ continue;
327
+ }
328
+
329
+ output.push(this.renderInlineAnnotationRow(annotated, width));
297
330
  }
298
331
 
299
332
  return output;
300
333
  }
301
334
 
302
- private renderDiffRows(width: number, height: number): string[] {
303
- return this.diffRenderMode === "split"
304
- ? this.renderSplitDiffRows(width, height)
305
- : this.renderUnifiedDiffRows(width, height);
335
+ private getAnnotatedRows(width: number): AnnotatedDiffRow[] {
336
+ if (
337
+ this.annotatedRows &&
338
+ this.annotatedRowsWidth === width &&
339
+ this.annotatedRowsRevision === this.commentsRevision &&
340
+ this.annotatedRowsEditMode === this.editMode &&
341
+ this.annotatedRowsEditingCommentKey === this.editingCommentKey &&
342
+ this.annotatedRowsMode === this.diffRenderMode &&
343
+ this.annotatedRowsInlineAnnotationsVisible ===
344
+ this.inlineAnnotationsVisible &&
345
+ this.annotatedRowsVisibleExplanationCount ===
346
+ this.visibleExplanationKeys.size &&
347
+ this.visibleExplanationKeys.size === 0
348
+ ) {
349
+ return this.annotatedRows;
350
+ }
351
+
352
+ const rows: AnnotatedDiffRow[] = [];
353
+ const rowByLineIndex: number[] = [];
354
+
355
+ this.pushGlobalAnnotationRows(rows, width);
356
+
357
+ if (this.diffRenderMode === "split") {
358
+ this.pushAnnotatedSplitRows(rows, rowByLineIndex, width);
359
+ } else {
360
+ this.pushAnnotatedUnifiedRows(rows, rowByLineIndex, width);
361
+ }
362
+
363
+ this.annotatedRows = rows;
364
+ this.annotatedRowsWidth = width;
365
+ this.annotatedRowsRevision = this.commentsRevision;
366
+ this.annotatedRowsEditMode = this.editMode;
367
+ this.annotatedRowsEditingCommentKey = this.editingCommentKey;
368
+ this.annotatedRowsMode = this.diffRenderMode;
369
+ this.annotatedRowsInlineAnnotationsVisible = this.inlineAnnotationsVisible;
370
+ this.annotatedRowsVisibleExplanationCount =
371
+ this.visibleExplanationKeys.size;
372
+ this.annotatedRowByLineIndex = rowByLineIndex;
373
+ return rows;
374
+ }
375
+
376
+ private pushAnnotatedUnifiedRows(
377
+ rows: AnnotatedDiffRow[],
378
+ rowByLineIndex: number[],
379
+ width: number,
380
+ ): void {
381
+ for (let index = 0; index < this.lines.length; index++) {
382
+ rowByLineIndex[index] = rows.length;
383
+ rows.push({ kind: "diff", lineIndex: index });
384
+
385
+ if (!this.inlineAnnotationsVisible) continue;
386
+
387
+ this.pushInlineCommentRows(rows, index, width);
388
+ this.pushInlineEditorRows(rows, index, width);
389
+ this.pushInlineExplanationRows(rows, index, width);
390
+ }
391
+ }
392
+
393
+ private pushAnnotatedSplitRows(
394
+ rows: AnnotatedDiffRow[],
395
+ rowByLineIndex: number[],
396
+ width: number,
397
+ ): void {
398
+ const splitRows = this.getSplitDiffRows();
399
+ for (
400
+ let splitRowIndex = 0;
401
+ splitRowIndex < splitRows.length;
402
+ splitRowIndex++
403
+ ) {
404
+ const splitRow = splitRows[splitRowIndex]!;
405
+ const lineIndexes = this.getLineIndexesForSplitRow(splitRow);
406
+ for (const lineIndex of lineIndexes) {
407
+ rowByLineIndex[lineIndex] = rows.length;
408
+ }
409
+
410
+ rows.push({ kind: "split", splitRowIndex });
411
+ if (!this.inlineAnnotationsVisible) continue;
412
+
413
+ for (const lineIndex of lineIndexes) {
414
+ this.pushInlineCommentRows(rows, lineIndex, width);
415
+ this.pushInlineEditorRows(rows, lineIndex, width);
416
+ this.pushInlineExplanationRows(rows, lineIndex, width);
417
+ }
418
+ }
419
+ }
420
+
421
+ private getLineIndexesForSplitRow(row: SplitDiffRow): number[] {
422
+ if (row.kind === "full") return [row.cell.index];
423
+ const indexes: number[] = [];
424
+ if (row.left) indexes.push(row.left.index);
425
+ if (row.right && row.right.index !== row.left?.index) {
426
+ indexes.push(row.right.index);
427
+ }
428
+ return indexes;
429
+ }
430
+
431
+ private pushGlobalAnnotationRows(
432
+ rows: AnnotatedDiffRow[],
433
+ width: number,
434
+ ): void {
435
+ if (!this.inlineAnnotationsVisible) return;
436
+
437
+ const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
438
+ if (globalComment) {
439
+ this.pushAnnotationBlock(
440
+ rows,
441
+ "comment",
442
+ globalComment.text,
443
+ width,
444
+ "Overall diff comment",
445
+ );
446
+ }
447
+
448
+ if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
449
+ this.pushInlineEditorBlock(rows, "Draft overall diff note", width);
450
+ }
451
+ }
452
+
453
+ private pushInlineCommentRows(
454
+ rows: AnnotatedDiffRow[],
455
+ lineIndex: number,
456
+ width: number,
457
+ ): void {
458
+ for (const comment of this.getCommentsEndingAtLine(lineIndex)) {
459
+ if (this.editMode && comment.id === this.editingCommentKey) continue;
460
+ this.pushAnnotationBlock(
461
+ rows,
462
+ "comment",
463
+ comment.text,
464
+ width,
465
+ formatCommentLocation(comment),
466
+ );
467
+ }
306
468
  }
307
469
 
308
- private renderFullWidthDiffRows(width: number, height: number): string[] {
309
- return this.renderDiffRows(width, height).map((row) =>
310
- padToWidth(truncateToWidth(row, width), width),
470
+ private pushInlineEditorRows(
471
+ rows: AnnotatedDiffRow[],
472
+ lineIndex: number,
473
+ width: number,
474
+ ): void {
475
+ if (!this.editMode || this.editingCommentKey === GLOBAL_COMMENT_KEY) return;
476
+ const selection = this.getActiveCommentSelection();
477
+ if (!selection || selection.end !== lineIndex) return;
478
+ this.pushInlineEditorBlock(
479
+ rows,
480
+ `Draft note - ${this.formatSelectionLocation(selection)}`,
481
+ width,
311
482
  );
312
483
  }
313
484
 
314
- private renderUnifiedDiffRows(width: number, height: number): string[] {
315
- const output: string[] = [];
316
- const selection = this.getSelectionBounds();
485
+ private pushInlineExplanationRows(
486
+ rows: AnnotatedDiffRow[],
487
+ lineIndex: number,
488
+ width: number,
489
+ ): void {
490
+ const scope = getCurrentHunkScope(this.lines, lineIndex);
491
+ if (!scope || !this.visibleExplanationKeys.has(scope.key)) return;
492
+
493
+ const end = this.getHunkEndIndex(lineIndex);
494
+ if (end !== lineIndex) return;
495
+
496
+ const explanation = this.explanationController.getState(scope);
497
+ if (!this.explanationController.isAvailable) {
498
+ this.pushExplanationBlock(rows, "Explanation unavailable.", width);
499
+ } else if (!explanation) {
500
+ this.pushExplanationBlock(rows, "No explanation generated yet.", width);
501
+ } else if (explanation.status === "loading") {
502
+ this.pushExplanationBlock(
503
+ rows,
504
+ `${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
505
+ width,
506
+ );
507
+ } else if (explanation.status === "error") {
508
+ this.pushExplanationBlock(
509
+ rows,
510
+ `Explanation failed: ${explanation.message}`,
511
+ width,
512
+ );
513
+ } else {
514
+ this.pushExplanationBlock(rows, explanation.text, width);
515
+ }
516
+ }
317
517
 
318
- for (let row = 0; row < height; row++) {
319
- const index = this.scrollTop + row;
320
- const line = this.lines[index];
321
- output.push(
322
- line
323
- ? this.renderDiffLine(
324
- line,
325
- index,
326
- width,
327
- index === this.selected,
328
- selection,
329
- )
330
- : " ".repeat(width),
518
+ private pushAnnotationBlock(
519
+ rows: AnnotatedDiffRow[],
520
+ kind: "comment",
521
+ text: string,
522
+ width: number,
523
+ title?: string,
524
+ ): void {
525
+ this.pushCommentBlock(rows, text, width, title);
526
+ }
527
+
528
+ private pushCommentBlock(
529
+ rows: AnnotatedDiffRow[],
530
+ text: string,
531
+ width: number,
532
+ title?: string,
533
+ ): void {
534
+ rows.push({
535
+ kind: "comment",
536
+ text: title ? this.theme.fg("accent", ` ${title} `) : "",
537
+ part: "top",
538
+ });
539
+ const wrapped = wrapTextWithAnsi(
540
+ this.theme.fg("text", text),
541
+ this.getInlineContentWidth(width),
542
+ );
543
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
544
+ rows.push({ kind: "comment", text: line, part: "body" });
545
+ }
546
+ rows.push({ kind: "comment", text: "", part: "bottom" });
547
+ }
548
+
549
+ private pushExplanationBlock(
550
+ rows: AnnotatedDiffRow[],
551
+ text: string,
552
+ width: number,
553
+ ): void {
554
+ rows.push({
555
+ kind: "explanation",
556
+ text: this.theme.fg("accent", " ✨ Explanation "),
557
+ part: "top",
558
+ });
559
+ const wrapped = wrapTextWithAnsi(
560
+ this.theme.fg("muted", text),
561
+ this.getInlineContentWidth(width),
562
+ );
563
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
564
+ rows.push({ kind: "explanation", text: line, part: "body" });
565
+ }
566
+ rows.push({ kind: "explanation", text: "", part: "bottom" });
567
+ }
568
+
569
+ private pushInlineEditorBlock(
570
+ rows: AnnotatedDiffRow[],
571
+ title: string,
572
+ width: number,
573
+ ): void {
574
+ rows.push({
575
+ kind: "editor",
576
+ text: this.theme.fg("accent", ` ${title} `),
577
+ part: "top",
578
+ });
579
+ const editorLines = this.editor.render(this.getInlineContentWidth(width));
580
+ const bodyLines = editorLines.slice(1, -1);
581
+ for (const line of bodyLines.length > 0 ? bodyLines : [""]) {
582
+ rows.push({ kind: "editor", text: line, part: "body" });
583
+ }
584
+ rows.push({ kind: "editor", text: "", part: "bottom" });
585
+ }
586
+
587
+ private renderInlineAnnotationRow(
588
+ row: Exclude<AnnotatedDiffRow, { kind: "diff" } | { kind: "split" }>,
589
+ width: number,
590
+ ): string {
591
+ if (row.kind === "editor") {
592
+ return this.renderInlineBoxRow(
593
+ { text: row.text, part: row.part },
594
+ width,
595
+ "accent",
331
596
  );
332
597
  }
333
598
 
334
- return output;
599
+ if (row.kind === "comment" || row.kind === "explanation") {
600
+ return this.renderInlineBoxRow(
601
+ { text: row.text, part: row.part },
602
+ width,
603
+ "borderMuted",
604
+ );
605
+ }
606
+
607
+ return " ".repeat(width);
335
608
  }
336
609
 
337
- private renderSplitDiffRows(width: number, height: number): string[] {
338
- const rows = this.getSplitDiffRows();
339
- const output: string[] = [];
340
- const separatorWidth = 3;
341
- const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
342
- const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
610
+ private renderInlineBoxRow(
611
+ row: { text: string; part: "top" | "body" | "bottom" },
612
+ width: number,
613
+ borderColor: "accent" | "borderMuted",
614
+ ): string {
615
+ const indent = " ";
616
+ const contentWidth = this.getInlineContentWidth(width);
617
+ const body =
618
+ row.part === "top" || row.part === "bottom"
619
+ ? this.renderInlineBoxHorizontal(row.text, contentWidth, borderColor)
620
+ : padToWidth(truncateToWidth(row.text, contentWidth), contentWidth);
621
+ const left = row.part === "top" ? "╭" : row.part === "bottom" ? "╰" : "│";
622
+ const right = row.part === "top" ? "╮" : row.part === "bottom" ? "╯" : "│";
623
+ return padToWidth(
624
+ truncateToWidth(
625
+ `${indent}${this.theme.fg(borderColor, left)}${body}${this.theme.fg(borderColor, right)}`,
626
+ width,
627
+ ),
628
+ width,
629
+ );
630
+ }
343
631
 
344
- for (let row = 0; row < height; row++) {
345
- const splitRow = rows[this.scrollTop + row];
346
- if (!splitRow) {
347
- output.push(" ".repeat(width));
348
- continue;
349
- }
632
+ private renderInlineBoxHorizontal(
633
+ title: string,
634
+ width: number,
635
+ borderColor: "accent" | "borderMuted",
636
+ ): string {
637
+ if (!title) return this.theme.fg(borderColor, "─".repeat(width));
350
638
 
351
- if (splitRow.kind === "full") {
352
- output.push(
353
- this.renderDiffLine(
354
- splitRow.cell.line,
355
- splitRow.cell.index,
356
- width,
357
- splitRow.cell.index === this.selected,
358
- this.getSelectionBounds(),
359
- ),
360
- );
361
- continue;
362
- }
639
+ const visibleTitle = truncateToWidth(title, Math.max(0, width));
640
+ const remaining = Math.max(0, width - visibleWidth(visibleTitle));
641
+ return `${visibleTitle}${this.theme.fg(borderColor, "─".repeat(remaining))}`;
642
+ }
363
643
 
364
- const left = splitRow.left
365
- ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
366
- : " ".repeat(leftWidth);
367
- const right = splitRow.right
368
- ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
369
- : " ".repeat(rightWidth);
370
- output.push(
371
- truncateToWidth(
372
- `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
373
- width,
374
- ),
644
+ private getInlineContentWidth(width: number): number {
645
+ return Math.max(10, width - 8);
646
+ }
647
+
648
+ private invalidateAnnotatedRows(): void {
649
+ this.annotatedRows = undefined;
650
+ this.annotatedRowByLineIndex = undefined;
651
+ }
652
+
653
+ private getCommentsEndingAtLine(lineIndex: number): ReviewComment[] {
654
+ const comments: ReviewComment[] = [];
655
+ for (const comment of this.comments.values()) {
656
+ if (comment.global) continue;
657
+ const start = this.lineIndexById.get(comment.startLineId);
658
+ const end = this.lineIndexById.get(comment.endLineId);
659
+ if (start == null || end == null) continue;
660
+ if (Math.max(start, end) === lineIndex) comments.push(comment);
661
+ }
662
+ return comments.sort((a, b) => a.id.localeCompare(b.id));
663
+ }
664
+
665
+ private getHunkEndIndex(selected: number): number | undefined {
666
+ const selectedLine = this.lines[selected];
667
+ if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
668
+
669
+ let end = selected;
670
+ while (
671
+ end + 1 < this.lines.length &&
672
+ this.lines[end + 1]?.filePath === selectedLine.filePath &&
673
+ this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
674
+ ) {
675
+ end++;
676
+ }
677
+ return end;
678
+ }
679
+
680
+ private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
681
+ const splitRow = this.getSplitDiffRows()[splitRowIndex];
682
+ if (!splitRow) return " ".repeat(width);
683
+
684
+ if (splitRow.kind === "full") {
685
+ return this.renderDiffLine(
686
+ splitRow.cell.line,
687
+ splitRow.cell.index,
688
+ width,
689
+ splitRow.cell.index === this.selected,
690
+ this.getSelectionBounds(),
375
691
  );
376
692
  }
377
693
 
378
- return output;
694
+ const separatorWidth = 3;
695
+ const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
696
+ const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
697
+ const left = splitRow.left
698
+ ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
699
+ : " ".repeat(leftWidth);
700
+ const right = splitRow.right
701
+ ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
702
+ : " ".repeat(rightWidth);
703
+ return truncateToWidth(
704
+ `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
705
+ width,
706
+ );
379
707
  }
380
708
 
381
709
  private getContentHeight(): number {
@@ -387,6 +715,7 @@ export class ReviewComponent {
387
715
 
388
716
  invalidate(): void {
389
717
  this.highlightedLineCache.clear();
718
+ this.invalidateAnnotatedRows();
390
719
  }
391
720
 
392
721
  dispose(): void {
@@ -409,23 +738,28 @@ export class ReviewComponent {
409
738
  this.tui.requestRender(true);
410
739
  }
411
740
 
412
- private toggleRightPane(): void {
413
- this.showRightPane = !this.showRightPane;
741
+ private toggleInlineAnnotations(): void {
742
+ this.inlineAnnotationsVisible = !this.inlineAnnotationsVisible;
743
+ this.invalidateAnnotatedRows();
414
744
  this.tui.requestRender(true);
415
745
  }
416
746
 
417
- private showCommentsPane(): void {
418
- this.showRightPane = true;
419
- this.rightPaneMode = "comments";
747
+ private hideInlineExplanation(): void {
748
+ this.visibleExplanationKeys.clear();
420
749
  }
421
750
 
422
751
  private toggleExplanationPane(): void {
423
- this.showRightPane = true;
424
- this.rightPaneMode =
425
- this.rightPaneMode === "comments" ? "explanation" : "comments";
426
- if (this.rightPaneMode === "explanation") {
752
+ const scope = this.getCurrentHunkScope();
753
+ if (!scope) return;
754
+
755
+ if (this.visibleExplanationKeys.has(scope.key)) {
756
+ this.visibleExplanationKeys.delete(scope.key);
757
+ } else {
758
+ this.visibleExplanationKeys.add(scope.key);
427
759
  this.ensureCurrentExplanation();
428
760
  }
761
+
762
+ this.invalidateAnnotatedRows();
429
763
  this.tui.requestRender(true);
430
764
  }
431
765
 
@@ -464,6 +798,16 @@ export class ReviewComponent {
464
798
  return getSelectionKey(this.lines, start, end);
465
799
  }
466
800
 
801
+ private formatSelectionLocation(selection: SelectionBounds): string {
802
+ const startLine = this.lines[selection.start];
803
+ const endLine = this.lines[selection.end];
804
+ if (!startLine || !endLine) return "diff";
805
+
806
+ const start = formatLocation(startLine);
807
+ const end = formatLocation(endLine);
808
+ return start === end ? start : `${start} -> ${end}`;
809
+ }
810
+
467
811
  private getCommentForSelection(
468
812
  selection: SelectionBounds | undefined,
469
813
  ): ReviewComment | undefined {
@@ -480,6 +824,7 @@ export class ReviewComponent {
480
824
 
481
825
  private markCommentsChanged(): void {
482
826
  this.commentsRevision++;
827
+ this.invalidateAnnotatedRows();
483
828
  this.onCommentsChanged?.(this.comments);
484
829
  }
485
830
 
@@ -536,10 +881,12 @@ export class ReviewComponent {
536
881
 
537
882
  private startGlobalEditMode(): void {
538
883
  const existing = this.comments.get(GLOBAL_COMMENT_KEY);
539
- this.showCommentsPane();
884
+ this.inlineAnnotationsVisible = true;
885
+ this.hideInlineExplanation();
540
886
  this.editMode = true;
541
887
  this.editingCommentKey = GLOBAL_COMMENT_KEY;
542
888
  this.editor.setText(existing?.text ?? "");
889
+ this.invalidateAnnotatedRows();
543
890
  this.tui.requestRender(true);
544
891
  }
545
892
 
@@ -552,13 +899,15 @@ export class ReviewComponent {
552
899
  if (startLine.filePath !== endLine.filePath) return;
553
900
 
554
901
  const existing = this.getCommentForSelection(selection);
555
- this.showCommentsPane();
902
+ this.inlineAnnotationsVisible = true;
903
+ this.hideInlineExplanation();
556
904
  this.editMode = true;
557
905
  this.editingCommentKey = this.getSelectionKey(
558
906
  selection.start,
559
907
  selection.end,
560
908
  );
561
909
  this.editor.setText(existing?.text ?? "");
910
+ this.invalidateAnnotatedRows();
562
911
  this.tui.requestRender(true);
563
912
  }
564
913
 
@@ -566,28 +915,25 @@ export class ReviewComponent {
566
915
  this.editMode = false;
567
916
  this.editingCommentKey = undefined;
568
917
  this.editor.setText("");
918
+ this.invalidateAnnotatedRows();
569
919
  this.tui.requestRender(true);
570
920
  }
571
921
 
572
922
  private getSplitDiffRows(): SplitDiffRow[] {
573
923
  if (this.splitRows) return this.splitRows;
574
924
 
575
- const { rows, rowByLineIndex } = buildSplitDiffRows(this.lines);
925
+ const { rows } = buildSplitDiffRows(this.lines);
576
926
  this.splitRows = rows;
577
- this.splitRowByLineIndex = rowByLineIndex;
578
927
  return rows;
579
928
  }
580
929
 
581
- private getSelectedDisplayRow(): number {
582
- if (this.diffRenderMode === "unified") return this.selected;
583
- this.getSplitDiffRows();
584
- return this.splitRowByLineIndex?.[this.selected] ?? 0;
930
+ private getSelectedDisplayRow(width: number): number {
931
+ this.getAnnotatedRows(width);
932
+ return this.annotatedRowByLineIndex?.[this.selected] ?? 0;
585
933
  }
586
934
 
587
- private getDisplayRowCount(): number {
588
- return this.diffRenderMode === "unified"
589
- ? this.lines.length
590
- : this.getSplitDiffRows().length;
935
+ private getDisplayRowCount(width: number): number {
936
+ return this.getAnnotatedRows(width).length;
591
937
  }
592
938
 
593
939
  private renderSplitDiffCell(
@@ -597,7 +943,7 @@ export class ReviewComponent {
597
943
  ): string {
598
944
  const { line, index } = cell;
599
945
  const hasComment = this.getCommentKeysForLine(index).length > 0;
600
- const commentMark = hasComment ? this.theme.fg("warning", "") : " ";
946
+ const commentMark = hasComment ? this.theme.fg("borderAccent", "") : " ";
601
947
  const lineNumber =
602
948
  side === "left" ? line.oldLineNumber : line.newLineNumber;
603
949
  const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
@@ -613,11 +959,11 @@ export class ReviewComponent {
613
959
  return this.applyDiffBackground(line, styled, width);
614
960
  }
615
961
 
616
- private ensureScroll(viewportHeight: number): void {
962
+ private ensureScroll(viewportHeight: number, width: number): void {
617
963
  this.navigation.ensureScroll(
618
964
  viewportHeight,
619
- this.getSelectedDisplayRow(),
620
- this.getDisplayRowCount(),
965
+ this.getSelectedDisplayRow(width),
966
+ this.getDisplayRowCount(width),
621
967
  );
622
968
  }
623
969
 
@@ -686,7 +1032,7 @@ export class ReviewComponent {
686
1032
  selection?: SelectionBounds,
687
1033
  ): string {
688
1034
  const hasComment = this.getCommentKeysForLine(index).length > 0;
689
- const commentMark = hasComment ? this.theme.fg("warning", "") : " ";
1035
+ const commentMark = hasComment ? this.theme.fg("borderAccent", "") : " ";
690
1036
  const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
691
1037
  const prefix = `${commentMark} ${numbers} `;
692
1038
  let styled = this.renderDiffRowContent(line, prefix);
@@ -700,54 +1046,6 @@ export class ReviewComponent {
700
1046
  return this.applyDiffBackground(line, styled, width);
701
1047
  }
702
1048
 
703
- private renderRightPane(
704
- width: number,
705
- height: number,
706
- selectedLine?: ReviewLine,
707
- ): string[] {
708
- return this.rightPaneMode === "explanation"
709
- ? this.renderExplanationPane(width, height, selectedLine)
710
- : this.renderCommentsPane(width, height, selectedLine);
711
- }
712
-
713
- private renderCommentsPane(
714
- width: number,
715
- height: number,
716
- selectedLine?: ReviewLine,
717
- ): string[] {
718
- const selection = this.getActiveCommentSelection();
719
- return renderCommentsPaneContent({
720
- width,
721
- height,
722
- selectedLine,
723
- theme: this.theme,
724
- lines: this.lines,
725
- comments: this.comments,
726
- editor: this.editor,
727
- editMode: this.editMode,
728
- editingCommentKey: this.editingCommentKey,
729
- selection,
730
- currentComment: this.getCommentForSelection(selection),
731
- footerText: this.getFooterText(selectedLine),
732
- hasSelection: this.hasSelection(),
733
- });
734
- }
735
-
736
- private renderExplanationPane(
737
- width: number,
738
- height: number,
739
- selectedLine?: ReviewLine,
740
- ): string[] {
741
- return renderExplanationPaneContent({
742
- width,
743
- height,
744
- selectedLine,
745
- theme: this.theme,
746
- scope: this.getCurrentHunkScope(),
747
- controller: this.explanationController,
748
- });
749
- }
750
-
751
1049
  private ensureCurrentExplanation(): void {
752
1050
  this.explanationController.ensure(this.getCurrentHunkScope());
753
1051
  }
package/src/types.ts CHANGED
@@ -59,5 +59,3 @@ export type ReviewTui = {
59
59
  };
60
60
 
61
61
  export type ReviewTheme = Theme;
62
-
63
- export type RightPaneMode = "comments" | "explanation";
@@ -1,286 +0,0 @@
1
- import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import type { Editor } from "@earendil-works/pi-tui";
3
- import type { ExplanationController } from "./explanation-controller.ts";
4
- import { GLOBAL_COMMENT_KEY } from "./comment-manager.ts";
5
- import { formatLocation } from "./prompt.ts";
6
- import type { ExplanationScope } from "./explain.ts";
7
- import type {
8
- ReviewComment,
9
- ReviewLine,
10
- ReviewTheme,
11
- SelectionBounds,
12
- } from "./types.ts";
13
-
14
- export type RenderCommentsPaneOptions = {
15
- width: number;
16
- height: number;
17
- selectedLine?: ReviewLine;
18
- theme: ReviewTheme;
19
- lines: ReviewLine[];
20
- comments: Map<string, ReviewComment>;
21
- editor: Editor;
22
- editMode: boolean;
23
- editingCommentKey?: string;
24
- selection: SelectionBounds | undefined;
25
- currentComment: ReviewComment | undefined;
26
- footerText: string;
27
- hasSelection: boolean;
28
- };
29
-
30
- export function renderCommentsPane({
31
- width,
32
- height,
33
- selectedLine,
34
- theme,
35
- lines: diffLines,
36
- comments,
37
- editor,
38
- editMode,
39
- editingCommentKey,
40
- selection,
41
- currentComment,
42
- footerText,
43
- hasSelection,
44
- }: RenderCommentsPaneOptions): string[] {
45
- const lines: string[] = [];
46
- const title = theme.fg("accent", theme.bold("Comments"));
47
-
48
- lines.push(truncateToWidth(title, width));
49
- lines.push(
50
- truncateToWidth(
51
- theme.fg(
52
- "dim",
53
- selection
54
- ? footerText
55
- : selectedLine
56
- ? formatLocation(selectedLine)
57
- : "No selection",
58
- ),
59
- width,
60
- ),
61
- );
62
- lines.push("");
63
-
64
- if (editMode && editingCommentKey === GLOBAL_COMMENT_KEY) {
65
- lines[1] = truncateToWidth(theme.fg("dim", "Overall diff comment"), width);
66
- lines.push(
67
- ...wrapTextWithAnsi(
68
- theme.fg(
69
- "dim",
70
- "Editing overall diff comment. Enter saves. Esc or Ctrl+C cancels.",
71
- ),
72
- width,
73
- ),
74
- );
75
- lines.push("");
76
- for (const line of editor.render(Math.max(10, width))) {
77
- lines.push(truncateToWidth(line, width));
78
- }
79
- return lines.slice(0, height);
80
- }
81
-
82
- const globalComment = comments.get(GLOBAL_COMMENT_KEY);
83
- if (globalComment) {
84
- lines.push(
85
- truncateToWidth(
86
- theme.fg("accent", theme.bold("Overall diff comment")),
87
- width,
88
- ),
89
- );
90
- lines.push(
91
- ...wrapTextWithAnsi(theme.fg("text", globalComment.text), width),
92
- );
93
- lines.push(...wrapTextWithAnsi(theme.fg("dim", "C edits"), width));
94
- lines.push("");
95
- }
96
-
97
- if (!selectedLine) {
98
- lines.push(
99
- ...wrapTextWithAnsi(theme.fg("muted", "No diff lines available."), width),
100
- );
101
- return lines.slice(0, height);
102
- }
103
-
104
- if (!selection) {
105
- lines.push(
106
- ...wrapTextWithAnsi(
107
- theme.fg(
108
- "muted",
109
- "Move to a diff line and press c to add a comment, or press C for an overall diff comment.",
110
- ),
111
- width,
112
- ),
113
- );
114
- return lines.slice(0, height);
115
- }
116
-
117
- if (editMode && currentComment?.id === editingCommentKey) {
118
- lines.push(
119
- ...wrapTextWithAnsi(
120
- theme.fg("dim", "Editing comment. Enter saves. Esc or Ctrl+C cancels."),
121
- width,
122
- ),
123
- );
124
- lines.push("");
125
- for (const line of editor.render(Math.max(10, width))) {
126
- lines.push(truncateToWidth(line, width));
127
- }
128
- } else if (editMode && editingCommentKey) {
129
- lines.push(
130
- ...wrapTextWithAnsi(
131
- theme.fg("dim", "Editing comment. Enter saves. Esc or Ctrl+C cancels."),
132
- width,
133
- ),
134
- );
135
- lines.push("");
136
- for (const line of editor.render(Math.max(10, width))) {
137
- lines.push(truncateToWidth(line, width));
138
- }
139
- } else if (currentComment) {
140
- lines.push(
141
- ...wrapTextWithAnsi(theme.fg("text", currentComment.text), width),
142
- );
143
- lines.push("");
144
- lines.push(
145
- ...wrapTextWithAnsi(
146
- theme.fg("dim", "x deletes this comment • c edits"),
147
- width,
148
- ),
149
- );
150
- } else {
151
- lines.push(
152
- ...wrapTextWithAnsi(
153
- theme.fg(
154
- "muted",
155
- hasSelection
156
- ? "No comment on this range."
157
- : "No comment on this line.",
158
- ),
159
- width,
160
- ),
161
- );
162
- lines.push("");
163
- lines.push(
164
- ...wrapTextWithAnsi(
165
- theme.fg(
166
- "dim",
167
- hasSelection
168
- ? "Press c to add a range comment, or C for an overall diff comment."
169
- : "Press c to add one. Use J/K to extend a range. Press C for an overall diff comment.",
170
- ),
171
- width,
172
- ),
173
- );
174
- }
175
-
176
- lines.push("");
177
- lines.push(truncateToWidth(theme.fg("accent", theme.bold("Excerpt")), width));
178
- const excerpt = diffLines
179
- .slice(selection.start, selection.end + 1)
180
- .map((line) => line.text)
181
- .join("\n");
182
- lines.push(
183
- ...wrapTextWithAnsi(
184
- theme.fg("toolDiffContext", excerpt || "(blank line)"),
185
- width,
186
- ),
187
- );
188
- return lines.slice(0, height);
189
- }
190
-
191
- export type RenderExplanationPaneOptions = {
192
- width: number;
193
- height: number;
194
- selectedLine?: ReviewLine;
195
- theme: ReviewTheme;
196
- scope: ExplanationScope | undefined;
197
- controller: ExplanationController;
198
- };
199
-
200
- export function renderExplanationPane({
201
- width,
202
- height,
203
- selectedLine,
204
- theme,
205
- scope,
206
- controller,
207
- }: RenderExplanationPaneOptions): string[] {
208
- const lines: string[] = [];
209
- const title = theme.fg("accent", theme.bold("Explanation"));
210
-
211
- lines.push(truncateToWidth(title, width));
212
- lines.push(
213
- truncateToWidth(
214
- theme.fg(
215
- "dim",
216
- scope?.title ??
217
- (selectedLine ? formatLocation(selectedLine) : "No selection"),
218
- ),
219
- width,
220
- ),
221
- );
222
- lines.push("");
223
-
224
- if (!controller.isAvailable) {
225
- lines.push(
226
- ...wrapTextWithAnsi(
227
- theme.fg("warning", "Diff explanations are unavailable."),
228
- width,
229
- ),
230
- );
231
- return lines.slice(0, height);
232
- }
233
-
234
- if (!scope) {
235
- lines.push(
236
- ...wrapTextWithAnsi(
237
- theme.fg(
238
- "muted",
239
- "Move to a changed hunk and press ? to generate an explanation.",
240
- ),
241
- width,
242
- ),
243
- );
244
- return lines.slice(0, height);
245
- }
246
-
247
- const explanation = controller.getState(scope);
248
- if (!explanation) {
249
- lines.push(
250
- ...wrapTextWithAnsi(
251
- theme.fg(
252
- "muted",
253
- "No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
254
- ),
255
- width,
256
- ),
257
- );
258
- } else if (explanation.status === "loading") {
259
- const spinner = controller.getLoadingFrame();
260
- lines.push(
261
- truncateToWidth(
262
- theme.fg("accent", `${spinner} Generating explanation...`),
263
- width,
264
- ),
265
- );
266
- if (explanation.text.trim()) {
267
- lines.push("");
268
- lines.push(
269
- ...wrapTextWithAnsi(theme.fg("text", explanation.text), width),
270
- );
271
- }
272
- } else if (explanation.status === "error") {
273
- lines.push(
274
- ...wrapTextWithAnsi(
275
- theme.fg("warning", `Unable to explain diff: ${explanation.message}`),
276
- width,
277
- ),
278
- );
279
- } else {
280
- lines.push(...wrapTextWithAnsi(theme.fg("text", explanation.text), width));
281
- }
282
-
283
- lines.push("");
284
- lines.push(...wrapTextWithAnsi(theme.fg("dim", "? comments"), width));
285
- return lines.slice(0, height);
286
- }