pi-diff-review 0.1.13 → 0.1.15

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,9 +65,10 @@ 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
+ - `/` searches diff lines; `n/N` moves between matches while a search is active
71
72
  - `J/K` to extend a highlighted selection into a comment range
72
73
  - `esc` clears the active selection, or exits review when no selection is active
73
74
  - `n/p` to jump hunks
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.15",
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,52 @@ 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;
57
+ private searchMode = false;
58
+ private searchQuery = "";
59
+ private draftSearchQuery = "";
60
+ private searchMessage = "";
43
61
 
44
- private showRightPane = true;
45
- private rightPaneMode: RightPaneMode = "comments";
62
+ private inlineAnnotationsVisible = true;
63
+ private visibleExplanationKeys = new Set<string>();
46
64
  private explanationController: ExplanationController;
47
65
  private editor: Editor;
48
66
  private splitRows?: SplitDiffRow[];
49
- private splitRowByLineIndex?: number[];
50
67
  private lineIndexById = new Map<string, number>();
51
68
  private commentLineKeys = new Map<number, string[]>();
52
69
  private commentsRevision = 0;
53
70
  private commentLineKeysRevision = -1;
54
71
  private highlightedLineCache = new Map<string, string>();
72
+ private annotatedRows?: AnnotatedDiffRow[];
73
+ private annotatedRowsWidth = 0;
74
+ private annotatedRowsRevision = -1;
75
+ private annotatedRowsEditMode = false;
76
+ private annotatedRowsEditingCommentKey?: string;
77
+ private annotatedRowsMode?: DiffRenderMode;
78
+ private annotatedRowsInlineAnnotationsVisible = true;
79
+ private annotatedRowsVisibleExplanationCount = 0;
80
+ private annotatedRowByLineIndex?: number[];
55
81
 
56
82
  constructor(
57
83
  private tui: ReviewTui,
@@ -120,6 +146,7 @@ export class ReviewComponent {
120
146
  this.markCommentsChanged();
121
147
  }
122
148
 
149
+ this.navigation.clearSelection();
123
150
  this.exitEditMode();
124
151
  };
125
152
  }
@@ -151,12 +178,20 @@ export class ReviewComponent {
151
178
  return;
152
179
  }
153
180
  this.editor.handleInput(data);
181
+ this.invalidateAnnotatedRows();
154
182
  this.tui.requestRender();
155
183
  return;
156
184
  }
157
185
 
186
+ if (this.searchMode) {
187
+ this.handleSearchInput(data);
188
+ return;
189
+ }
190
+
158
191
  if (matchesKey(data, "escape")) {
159
- if (this.hasSelection()) {
192
+ if (this.searchQuery) {
193
+ this.clearSearch();
194
+ } else if (this.hasSelection()) {
160
195
  this.clearSelection();
161
196
  } else {
162
197
  this.done({ action: "cancel" });
@@ -168,7 +203,7 @@ export class ReviewComponent {
168
203
  return;
169
204
  }
170
205
  if (data === "t") {
171
- this.toggleRightPane();
206
+ this.toggleInlineAnnotations();
172
207
  return;
173
208
  }
174
209
  if (data === "v") {
@@ -179,6 +214,10 @@ export class ReviewComponent {
179
214
  this.toggleExplanationPane();
180
215
  return;
181
216
  }
217
+ if (data === "/") {
218
+ this.startSearchMode();
219
+ return;
220
+ }
182
221
  if (matchesKey(data, "ctrl+d")) {
183
222
  this.move(this.getPageMoveAmount());
184
223
  return;
@@ -212,7 +251,15 @@ export class ReviewComponent {
212
251
  return;
213
252
  }
214
253
  if (data === "n") {
215
- this.jumpHunk(1);
254
+ if (this.searchQuery) {
255
+ this.jumpSearch(1);
256
+ } else {
257
+ this.jumpHunk(1);
258
+ }
259
+ return;
260
+ }
261
+ if (data === "N" && this.searchQuery) {
262
+ this.jumpSearch(-1);
216
263
  return;
217
264
  }
218
265
  if (data === "p") {
@@ -250,23 +297,17 @@ export class ReviewComponent {
250
297
  this.theme.fg(
251
298
  "dim",
252
299
  this.editMode
253
- ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
300
+ ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
254
301
  : 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`,
302
+ ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
303
+ : `${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 • / search • t annotations • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • ${this.searchQuery ? "n/N search" : "n/p hunk"} • Enter submit • q quit`,
257
304
  ),
258
305
  width,
259
306
  ),
260
307
  );
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
- }
308
+
309
+ this.ensureScroll(viewportHeight, width);
310
+ output.push(...this.renderAnnotatedDiffRows(width, viewportHeight));
270
311
 
271
312
  output.push(
272
313
  truncateToWidth(
@@ -277,105 +318,415 @@ export class ReviewComponent {
277
318
  return output;
278
319
  }
279
320
 
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);
321
+ private renderAnnotatedDiffRows(width: number, height: number): string[] {
322
+ const rows = this.getAnnotatedRows(width);
289
323
  const output: string[] = [];
290
- const diffPane = this.renderDiffRows(leftWidth, height);
291
324
 
292
325
  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));
326
+ const annotated = rows[this.scrollTop + row];
327
+ if (!annotated) {
328
+ output.push(" ".repeat(width));
329
+ continue;
330
+ }
331
+
332
+ if (annotated.kind === "diff") {
333
+ const index = annotated.lineIndex;
334
+ const line = this.lines[index]!;
335
+ output.push(
336
+ this.renderDiffLine(
337
+ line,
338
+ index,
339
+ width,
340
+ index === this.selected,
341
+ this.getSelectionBounds(),
342
+ ),
343
+ );
344
+ continue;
345
+ }
346
+
347
+ if (annotated.kind === "split") {
348
+ output.push(this.renderSplitDiffRowAt(annotated.splitRowIndex, width));
349
+ continue;
350
+ }
351
+
352
+ output.push(this.renderInlineAnnotationRow(annotated, width));
297
353
  }
298
354
 
299
355
  return output;
300
356
  }
301
357
 
302
- private renderDiffRows(width: number, height: number): string[] {
303
- return this.diffRenderMode === "split"
304
- ? this.renderSplitDiffRows(width, height)
305
- : this.renderUnifiedDiffRows(width, height);
358
+ private getAnnotatedRows(width: number): AnnotatedDiffRow[] {
359
+ if (
360
+ this.annotatedRows &&
361
+ this.annotatedRowsWidth === width &&
362
+ this.annotatedRowsRevision === this.commentsRevision &&
363
+ this.annotatedRowsEditMode === this.editMode &&
364
+ this.annotatedRowsEditingCommentKey === this.editingCommentKey &&
365
+ this.annotatedRowsMode === this.diffRenderMode &&
366
+ this.annotatedRowsInlineAnnotationsVisible ===
367
+ this.inlineAnnotationsVisible &&
368
+ this.annotatedRowsVisibleExplanationCount ===
369
+ this.visibleExplanationKeys.size &&
370
+ this.visibleExplanationKeys.size === 0
371
+ ) {
372
+ return this.annotatedRows;
373
+ }
374
+
375
+ const rows: AnnotatedDiffRow[] = [];
376
+ const rowByLineIndex: number[] = [];
377
+
378
+ this.pushGlobalAnnotationRows(rows, width);
379
+
380
+ if (this.diffRenderMode === "split") {
381
+ this.pushAnnotatedSplitRows(rows, rowByLineIndex, width);
382
+ } else {
383
+ this.pushAnnotatedUnifiedRows(rows, rowByLineIndex, width);
384
+ }
385
+
386
+ this.annotatedRows = rows;
387
+ this.annotatedRowsWidth = width;
388
+ this.annotatedRowsRevision = this.commentsRevision;
389
+ this.annotatedRowsEditMode = this.editMode;
390
+ this.annotatedRowsEditingCommentKey = this.editingCommentKey;
391
+ this.annotatedRowsMode = this.diffRenderMode;
392
+ this.annotatedRowsInlineAnnotationsVisible = this.inlineAnnotationsVisible;
393
+ this.annotatedRowsVisibleExplanationCount =
394
+ this.visibleExplanationKeys.size;
395
+ this.annotatedRowByLineIndex = rowByLineIndex;
396
+ return rows;
397
+ }
398
+
399
+ private pushAnnotatedUnifiedRows(
400
+ rows: AnnotatedDiffRow[],
401
+ rowByLineIndex: number[],
402
+ width: number,
403
+ ): void {
404
+ for (let index = 0; index < this.lines.length; index++) {
405
+ rowByLineIndex[index] = rows.length;
406
+ rows.push({ kind: "diff", lineIndex: index });
407
+
408
+ if (!this.inlineAnnotationsVisible) continue;
409
+
410
+ this.pushInlineCommentRows(rows, index, width);
411
+ this.pushInlineEditorRows(rows, index, width);
412
+ this.pushInlineExplanationRows(rows, index, width);
413
+ }
414
+ }
415
+
416
+ private pushAnnotatedSplitRows(
417
+ rows: AnnotatedDiffRow[],
418
+ rowByLineIndex: number[],
419
+ width: number,
420
+ ): void {
421
+ const splitRows = this.getSplitDiffRows();
422
+ for (
423
+ let splitRowIndex = 0;
424
+ splitRowIndex < splitRows.length;
425
+ splitRowIndex++
426
+ ) {
427
+ const splitRow = splitRows[splitRowIndex]!;
428
+ const lineIndexes = this.getLineIndexesForSplitRow(splitRow);
429
+ for (const lineIndex of lineIndexes) {
430
+ rowByLineIndex[lineIndex] = rows.length;
431
+ }
432
+
433
+ rows.push({ kind: "split", splitRowIndex });
434
+ if (!this.inlineAnnotationsVisible) continue;
435
+
436
+ for (const lineIndex of lineIndexes) {
437
+ this.pushInlineCommentRows(rows, lineIndex, width);
438
+ this.pushInlineEditorRows(rows, lineIndex, width);
439
+ this.pushInlineExplanationRows(rows, lineIndex, width);
440
+ }
441
+ }
306
442
  }
307
443
 
308
- private renderFullWidthDiffRows(width: number, height: number): string[] {
309
- return this.renderDiffRows(width, height).map((row) =>
310
- padToWidth(truncateToWidth(row, width), width),
444
+ private getLineIndexesForSplitRow(row: SplitDiffRow): number[] {
445
+ if (row.kind === "full") return [row.cell.index];
446
+ const indexes: number[] = [];
447
+ if (row.left) indexes.push(row.left.index);
448
+ if (row.right && row.right.index !== row.left?.index) {
449
+ indexes.push(row.right.index);
450
+ }
451
+ return indexes;
452
+ }
453
+
454
+ private pushGlobalAnnotationRows(
455
+ rows: AnnotatedDiffRow[],
456
+ width: number,
457
+ ): void {
458
+ if (!this.inlineAnnotationsVisible) return;
459
+
460
+ const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
461
+ if (globalComment) {
462
+ this.pushAnnotationBlock(
463
+ rows,
464
+ "comment",
465
+ globalComment.text,
466
+ width,
467
+ "Overall diff comment",
468
+ );
469
+ }
470
+
471
+ if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
472
+ this.pushInlineEditorBlock(rows, "Draft overall diff note", width);
473
+ }
474
+ }
475
+
476
+ private pushInlineCommentRows(
477
+ rows: AnnotatedDiffRow[],
478
+ lineIndex: number,
479
+ width: number,
480
+ ): void {
481
+ for (const comment of this.getCommentsEndingAtLine(lineIndex)) {
482
+ if (this.editMode && comment.id === this.editingCommentKey) continue;
483
+ this.pushAnnotationBlock(
484
+ rows,
485
+ "comment",
486
+ comment.text,
487
+ width,
488
+ formatCommentLocation(comment),
489
+ );
490
+ }
491
+ }
492
+
493
+ private pushInlineEditorRows(
494
+ rows: AnnotatedDiffRow[],
495
+ lineIndex: number,
496
+ width: number,
497
+ ): void {
498
+ if (!this.editMode || this.editingCommentKey === GLOBAL_COMMENT_KEY) return;
499
+ const selection = this.getActiveCommentSelection();
500
+ if (!selection || selection.end !== lineIndex) return;
501
+ this.pushInlineEditorBlock(
502
+ rows,
503
+ `Draft note - ${this.formatSelectionLocation(selection)}`,
504
+ width,
311
505
  );
312
506
  }
313
507
 
314
- private renderUnifiedDiffRows(width: number, height: number): string[] {
315
- const output: string[] = [];
316
- const selection = this.getSelectionBounds();
508
+ private pushInlineExplanationRows(
509
+ rows: AnnotatedDiffRow[],
510
+ lineIndex: number,
511
+ width: number,
512
+ ): void {
513
+ const scope = getCurrentHunkScope(this.lines, lineIndex);
514
+ if (!scope || !this.visibleExplanationKeys.has(scope.key)) return;
515
+
516
+ const end = this.getHunkEndIndex(lineIndex);
517
+ if (end !== lineIndex) return;
518
+
519
+ const explanation = this.explanationController.getState(scope);
520
+ if (!this.explanationController.isAvailable) {
521
+ this.pushExplanationBlock(rows, "Explanation unavailable.", width);
522
+ } else if (!explanation) {
523
+ this.pushExplanationBlock(rows, "No explanation generated yet.", width);
524
+ } else if (explanation.status === "loading") {
525
+ this.pushExplanationBlock(
526
+ rows,
527
+ `${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
528
+ width,
529
+ );
530
+ } else if (explanation.status === "error") {
531
+ this.pushExplanationBlock(
532
+ rows,
533
+ `Explanation failed: ${explanation.message}`,
534
+ width,
535
+ );
536
+ } else {
537
+ this.pushExplanationBlock(rows, explanation.text, width);
538
+ }
539
+ }
317
540
 
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),
541
+ private pushAnnotationBlock(
542
+ rows: AnnotatedDiffRow[],
543
+ kind: "comment",
544
+ text: string,
545
+ width: number,
546
+ title?: string,
547
+ ): void {
548
+ this.pushCommentBlock(rows, text, width, title);
549
+ }
550
+
551
+ private pushCommentBlock(
552
+ rows: AnnotatedDiffRow[],
553
+ text: string,
554
+ width: number,
555
+ title?: string,
556
+ ): void {
557
+ rows.push({
558
+ kind: "comment",
559
+ text: title ? this.theme.fg("accent", ` ${title} `) : "",
560
+ part: "top",
561
+ });
562
+ const wrapped = wrapTextWithAnsi(
563
+ this.theme.fg("text", text),
564
+ this.getInlineContentWidth(width),
565
+ );
566
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
567
+ rows.push({ kind: "comment", text: line, part: "body" });
568
+ }
569
+ rows.push({ kind: "comment", text: "", part: "bottom" });
570
+ }
571
+
572
+ private pushExplanationBlock(
573
+ rows: AnnotatedDiffRow[],
574
+ text: string,
575
+ width: number,
576
+ ): void {
577
+ rows.push({
578
+ kind: "explanation",
579
+ text: this.theme.fg("accent", " ✨ Explanation "),
580
+ part: "top",
581
+ });
582
+ const wrapped = wrapTextWithAnsi(
583
+ this.theme.fg("muted", text),
584
+ this.getInlineContentWidth(width),
585
+ );
586
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
587
+ rows.push({ kind: "explanation", text: line, part: "body" });
588
+ }
589
+ rows.push({ kind: "explanation", text: "", part: "bottom" });
590
+ }
591
+
592
+ private pushInlineEditorBlock(
593
+ rows: AnnotatedDiffRow[],
594
+ title: string,
595
+ width: number,
596
+ ): void {
597
+ rows.push({
598
+ kind: "editor",
599
+ text: this.theme.fg("accent", ` ${title} `),
600
+ part: "top",
601
+ });
602
+ const editorLines = this.editor.render(this.getInlineContentWidth(width));
603
+ const bodyLines = editorLines.slice(1, -1);
604
+ for (const line of bodyLines.length > 0 ? bodyLines : [""]) {
605
+ rows.push({ kind: "editor", text: line, part: "body" });
606
+ }
607
+ rows.push({ kind: "editor", text: "", part: "bottom" });
608
+ }
609
+
610
+ private renderInlineAnnotationRow(
611
+ row: Exclude<AnnotatedDiffRow, { kind: "diff" } | { kind: "split" }>,
612
+ width: number,
613
+ ): string {
614
+ if (row.kind === "editor") {
615
+ return this.renderInlineBoxRow(
616
+ { text: row.text, part: row.part },
617
+ width,
618
+ "accent",
331
619
  );
332
620
  }
333
621
 
334
- return output;
622
+ if (row.kind === "comment" || row.kind === "explanation") {
623
+ return this.renderInlineBoxRow(
624
+ { text: row.text, part: row.part },
625
+ width,
626
+ "borderMuted",
627
+ );
628
+ }
629
+
630
+ return " ".repeat(width);
335
631
  }
336
632
 
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);
633
+ private renderInlineBoxRow(
634
+ row: { text: string; part: "top" | "body" | "bottom" },
635
+ width: number,
636
+ borderColor: "accent" | "borderMuted",
637
+ ): string {
638
+ const indent = " ";
639
+ const contentWidth = this.getInlineContentWidth(width);
640
+ const body =
641
+ row.part === "top" || row.part === "bottom"
642
+ ? this.renderInlineBoxHorizontal(row.text, contentWidth, borderColor)
643
+ : padToWidth(truncateToWidth(row.text, contentWidth), contentWidth);
644
+ const left = row.part === "top" ? "╭" : row.part === "bottom" ? "╰" : "│";
645
+ const right = row.part === "top" ? "╮" : row.part === "bottom" ? "╯" : "│";
646
+ return padToWidth(
647
+ truncateToWidth(
648
+ `${indent}${this.theme.fg(borderColor, left)}${body}${this.theme.fg(borderColor, right)}`,
649
+ width,
650
+ ),
651
+ width,
652
+ );
653
+ }
343
654
 
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
- }
655
+ private renderInlineBoxHorizontal(
656
+ title: string,
657
+ width: number,
658
+ borderColor: "accent" | "borderMuted",
659
+ ): string {
660
+ if (!title) return this.theme.fg(borderColor, "─".repeat(width));
350
661
 
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
- }
662
+ const visibleTitle = truncateToWidth(title, Math.max(0, width));
663
+ const remaining = Math.max(0, width - visibleWidth(visibleTitle));
664
+ return `${visibleTitle}${this.theme.fg(borderColor, "─".repeat(remaining))}`;
665
+ }
363
666
 
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
- ),
667
+ private getInlineContentWidth(width: number): number {
668
+ return Math.max(10, width - 8);
669
+ }
670
+
671
+ private invalidateAnnotatedRows(): void {
672
+ this.annotatedRows = undefined;
673
+ this.annotatedRowByLineIndex = undefined;
674
+ }
675
+
676
+ private getCommentsEndingAtLine(lineIndex: number): ReviewComment[] {
677
+ const comments: ReviewComment[] = [];
678
+ for (const comment of this.comments.values()) {
679
+ if (comment.global) continue;
680
+ const start = this.lineIndexById.get(comment.startLineId);
681
+ const end = this.lineIndexById.get(comment.endLineId);
682
+ if (start == null || end == null) continue;
683
+ if (Math.max(start, end) === lineIndex) comments.push(comment);
684
+ }
685
+ return comments.sort((a, b) => a.id.localeCompare(b.id));
686
+ }
687
+
688
+ private getHunkEndIndex(selected: number): number | undefined {
689
+ const selectedLine = this.lines[selected];
690
+ if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
691
+
692
+ let end = selected;
693
+ while (
694
+ end + 1 < this.lines.length &&
695
+ this.lines[end + 1]?.filePath === selectedLine.filePath &&
696
+ this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
697
+ ) {
698
+ end++;
699
+ }
700
+ return end;
701
+ }
702
+
703
+ private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
704
+ const splitRow = this.getSplitDiffRows()[splitRowIndex];
705
+ if (!splitRow) return " ".repeat(width);
706
+
707
+ if (splitRow.kind === "full") {
708
+ return this.renderDiffLine(
709
+ splitRow.cell.line,
710
+ splitRow.cell.index,
711
+ width,
712
+ splitRow.cell.index === this.selected,
713
+ this.getSelectionBounds(),
375
714
  );
376
715
  }
377
716
 
378
- return output;
717
+ const separatorWidth = 3;
718
+ const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
719
+ const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
720
+ const left = splitRow.left
721
+ ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
722
+ : " ".repeat(leftWidth);
723
+ const right = splitRow.right
724
+ ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
725
+ : " ".repeat(rightWidth);
726
+ return truncateToWidth(
727
+ `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
728
+ width,
729
+ );
379
730
  }
380
731
 
381
732
  private getContentHeight(): number {
@@ -387,6 +738,7 @@ export class ReviewComponent {
387
738
 
388
739
  invalidate(): void {
389
740
  this.highlightedLineCache.clear();
741
+ this.invalidateAnnotatedRows();
390
742
  }
391
743
 
392
744
  dispose(): void {
@@ -409,23 +761,28 @@ export class ReviewComponent {
409
761
  this.tui.requestRender(true);
410
762
  }
411
763
 
412
- private toggleRightPane(): void {
413
- this.showRightPane = !this.showRightPane;
764
+ private toggleInlineAnnotations(): void {
765
+ this.inlineAnnotationsVisible = !this.inlineAnnotationsVisible;
766
+ this.invalidateAnnotatedRows();
414
767
  this.tui.requestRender(true);
415
768
  }
416
769
 
417
- private showCommentsPane(): void {
418
- this.showRightPane = true;
419
- this.rightPaneMode = "comments";
770
+ private hideInlineExplanation(): void {
771
+ this.visibleExplanationKeys.clear();
420
772
  }
421
773
 
422
774
  private toggleExplanationPane(): void {
423
- this.showRightPane = true;
424
- this.rightPaneMode =
425
- this.rightPaneMode === "comments" ? "explanation" : "comments";
426
- if (this.rightPaneMode === "explanation") {
775
+ const scope = this.getCurrentHunkScope();
776
+ if (!scope) return;
777
+
778
+ if (this.visibleExplanationKeys.has(scope.key)) {
779
+ this.visibleExplanationKeys.delete(scope.key);
780
+ } else {
781
+ this.visibleExplanationKeys.add(scope.key);
427
782
  this.ensureCurrentExplanation();
428
783
  }
784
+
785
+ this.invalidateAnnotatedRows();
429
786
  this.tui.requestRender(true);
430
787
  }
431
788
 
@@ -464,6 +821,16 @@ export class ReviewComponent {
464
821
  return getSelectionKey(this.lines, start, end);
465
822
  }
466
823
 
824
+ private formatSelectionLocation(selection: SelectionBounds): string {
825
+ const startLine = this.lines[selection.start];
826
+ const endLine = this.lines[selection.end];
827
+ if (!startLine || !endLine) return "diff";
828
+
829
+ const start = formatLocation(startLine);
830
+ const end = formatLocation(endLine);
831
+ return start === end ? start : `${start} -> ${end}`;
832
+ }
833
+
467
834
  private getCommentForSelection(
468
835
  selection: SelectionBounds | undefined,
469
836
  ): ReviewComment | undefined {
@@ -480,6 +847,7 @@ export class ReviewComponent {
480
847
 
481
848
  private markCommentsChanged(): void {
482
849
  this.commentsRevision++;
850
+ this.invalidateAnnotatedRows();
483
851
  this.onCommentsChanged?.(this.comments);
484
852
  }
485
853
 
@@ -501,6 +869,13 @@ export class ReviewComponent {
501
869
  }
502
870
 
503
871
  private getFooterText(selectedLine?: ReviewLine): string {
872
+ if (this.searchMode) {
873
+ return `Search: /${this.draftSearchQuery} • Enter jump • Esc cancel`;
874
+ }
875
+
876
+ const searchStatus = this.getSearchStatusText();
877
+ if (searchStatus) return searchStatus;
878
+
504
879
  const selection = this.getSelectionBounds();
505
880
  if (selection) {
506
881
  const count = selection.end - selection.start + 1;
@@ -511,6 +886,21 @@ export class ReviewComponent {
511
886
  return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
512
887
  }
513
888
 
889
+ private getSearchStatusText(): string {
890
+ if (this.searchMessage) return this.searchMessage;
891
+ if (!this.searchQuery) return "";
892
+
893
+ const matches = this.getSearchMatchIndexes(this.searchQuery);
894
+ if (matches.length === 0) return `No matches for /${this.searchQuery}`;
895
+
896
+ const current = matches.findIndex((index) => index === this.selected);
897
+ const position =
898
+ current >= 0
899
+ ? `${current + 1}/${matches.length}`
900
+ : `${matches.length} matches`;
901
+ return `Search /${this.searchQuery} • ${position} • n next • N previous • Esc clear search`;
902
+ }
903
+
514
904
  private jumpHunk(direction: 1 | -1): void {
515
905
  let index = this.selected + direction;
516
906
  while (index >= 0 && index < this.lines.length) {
@@ -536,10 +926,12 @@ export class ReviewComponent {
536
926
 
537
927
  private startGlobalEditMode(): void {
538
928
  const existing = this.comments.get(GLOBAL_COMMENT_KEY);
539
- this.showCommentsPane();
929
+ this.inlineAnnotationsVisible = true;
930
+ this.hideInlineExplanation();
540
931
  this.editMode = true;
541
932
  this.editingCommentKey = GLOBAL_COMMENT_KEY;
542
933
  this.editor.setText(existing?.text ?? "");
934
+ this.invalidateAnnotatedRows();
543
935
  this.tui.requestRender(true);
544
936
  }
545
937
 
@@ -552,13 +944,15 @@ export class ReviewComponent {
552
944
  if (startLine.filePath !== endLine.filePath) return;
553
945
 
554
946
  const existing = this.getCommentForSelection(selection);
555
- this.showCommentsPane();
947
+ this.inlineAnnotationsVisible = true;
948
+ this.hideInlineExplanation();
556
949
  this.editMode = true;
557
950
  this.editingCommentKey = this.getSelectionKey(
558
951
  selection.start,
559
952
  selection.end,
560
953
  );
561
954
  this.editor.setText(existing?.text ?? "");
955
+ this.invalidateAnnotatedRows();
562
956
  this.tui.requestRender(true);
563
957
  }
564
958
 
@@ -566,28 +960,112 @@ export class ReviewComponent {
566
960
  this.editMode = false;
567
961
  this.editingCommentKey = undefined;
568
962
  this.editor.setText("");
963
+ this.invalidateAnnotatedRows();
569
964
  this.tui.requestRender(true);
570
965
  }
571
966
 
967
+ private startSearchMode(): void {
968
+ this.searchMode = true;
969
+ this.draftSearchQuery = this.searchQuery;
970
+ this.searchMessage = "";
971
+ this.tui.requestRender();
972
+ }
973
+
974
+ private handleSearchInput(data: string): void {
975
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
976
+ this.searchMode = false;
977
+ this.draftSearchQuery = "";
978
+ this.tui.requestRender();
979
+ return;
980
+ }
981
+
982
+ if (matchesKey(data, "enter")) {
983
+ const query = this.draftSearchQuery.trim();
984
+ this.searchMode = false;
985
+ this.draftSearchQuery = "";
986
+ this.searchQuery = query;
987
+ this.searchMessage = "";
988
+ if (query) {
989
+ this.jumpSearch(1);
990
+ } else {
991
+ this.tui.requestRender();
992
+ }
993
+ return;
994
+ }
995
+
996
+ if (matchesKey(data, "ctrl+u")) {
997
+ this.draftSearchQuery = "";
998
+ this.tui.requestRender();
999
+ return;
1000
+ }
1001
+
1002
+ if (matchesKey(data, "backspace") || data === "\u007f" || data === "\b") {
1003
+ this.draftSearchQuery = this.draftSearchQuery.slice(0, -1);
1004
+ this.tui.requestRender();
1005
+ return;
1006
+ }
1007
+
1008
+ if (data.length === 1 && data >= " " && data !== "\u007f") {
1009
+ this.draftSearchQuery += data;
1010
+ this.tui.requestRender();
1011
+ }
1012
+ }
1013
+
1014
+ private clearSearch(): void {
1015
+ this.searchMode = false;
1016
+ this.searchQuery = "";
1017
+ this.draftSearchQuery = "";
1018
+ this.searchMessage = "";
1019
+ this.tui.requestRender();
1020
+ }
1021
+
1022
+ private jumpSearch(direction: 1 | -1): void {
1023
+ const query = this.searchQuery.trim();
1024
+ if (!query) return;
1025
+
1026
+ const matches = this.getSearchMatchIndexes(query);
1027
+ if (matches.length === 0) {
1028
+ this.searchMessage = `No matches for /${query}`;
1029
+ this.tui.requestRender();
1030
+ return;
1031
+ }
1032
+
1033
+ this.searchMessage = "";
1034
+ const current =
1035
+ direction === 1
1036
+ ? matches.find((index) => index > this.selected)
1037
+ : [...matches].reverse().find((index) => index < this.selected);
1038
+ this.selected =
1039
+ current ?? (direction === 1 ? matches[0]! : matches[matches.length - 1]!);
1040
+ this.tui.requestRender();
1041
+ }
1042
+
1043
+ private getSearchMatchIndexes(query: string): number[] {
1044
+ const needle = query.toLocaleLowerCase();
1045
+ if (!needle) return [];
1046
+
1047
+ const matches: number[] = [];
1048
+ this.lines.forEach((line, index) => {
1049
+ if (line.text.toLocaleLowerCase().includes(needle)) matches.push(index);
1050
+ });
1051
+ return matches;
1052
+ }
1053
+
572
1054
  private getSplitDiffRows(): SplitDiffRow[] {
573
1055
  if (this.splitRows) return this.splitRows;
574
1056
 
575
- const { rows, rowByLineIndex } = buildSplitDiffRows(this.lines);
1057
+ const { rows } = buildSplitDiffRows(this.lines);
576
1058
  this.splitRows = rows;
577
- this.splitRowByLineIndex = rowByLineIndex;
578
1059
  return rows;
579
1060
  }
580
1061
 
581
- private getSelectedDisplayRow(): number {
582
- if (this.diffRenderMode === "unified") return this.selected;
583
- this.getSplitDiffRows();
584
- return this.splitRowByLineIndex?.[this.selected] ?? 0;
1062
+ private getSelectedDisplayRow(width: number): number {
1063
+ this.getAnnotatedRows(width);
1064
+ return this.annotatedRowByLineIndex?.[this.selected] ?? 0;
585
1065
  }
586
1066
 
587
- private getDisplayRowCount(): number {
588
- return this.diffRenderMode === "unified"
589
- ? this.lines.length
590
- : this.getSplitDiffRows().length;
1067
+ private getDisplayRowCount(width: number): number {
1068
+ return this.getAnnotatedRows(width).length;
591
1069
  }
592
1070
 
593
1071
  private renderSplitDiffCell(
@@ -597,7 +1075,7 @@ export class ReviewComponent {
597
1075
  ): string {
598
1076
  const { line, index } = cell;
599
1077
  const hasComment = this.getCommentKeysForLine(index).length > 0;
600
- const commentMark = hasComment ? this.theme.fg("warning", "") : " ";
1078
+ const commentMark = hasComment ? this.theme.fg("borderAccent", "") : " ";
601
1079
  const lineNumber =
602
1080
  side === "left" ? line.oldLineNumber : line.newLineNumber;
603
1081
  const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
@@ -613,11 +1091,11 @@ export class ReviewComponent {
613
1091
  return this.applyDiffBackground(line, styled, width);
614
1092
  }
615
1093
 
616
- private ensureScroll(viewportHeight: number): void {
1094
+ private ensureScroll(viewportHeight: number, width: number): void {
617
1095
  this.navigation.ensureScroll(
618
1096
  viewportHeight,
619
- this.getSelectedDisplayRow(),
620
- this.getDisplayRowCount(),
1097
+ this.getSelectedDisplayRow(width),
1098
+ this.getDisplayRowCount(width),
621
1099
  );
622
1100
  }
623
1101
 
@@ -686,7 +1164,7 @@ export class ReviewComponent {
686
1164
  selection?: SelectionBounds,
687
1165
  ): string {
688
1166
  const hasComment = this.getCommentKeysForLine(index).length > 0;
689
- const commentMark = hasComment ? this.theme.fg("warning", "") : " ";
1167
+ const commentMark = hasComment ? this.theme.fg("borderAccent", "") : " ";
690
1168
  const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
691
1169
  const prefix = `${commentMark} ${numbers} `;
692
1170
  let styled = this.renderDiffRowContent(line, prefix);
@@ -700,54 +1178,6 @@ export class ReviewComponent {
700
1178
  return this.applyDiffBackground(line, styled, width);
701
1179
  }
702
1180
 
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
1181
  private ensureCurrentExplanation(): void {
752
1182
  this.explanationController.ensure(this.getCurrentHunkScope());
753
1183
  }
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
- }