pi-diff-review 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,808 @@
1
+ import {
2
+ Editor,
3
+ matchesKey,
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ wrapTextWithAnsi,
7
+ } from "@mariozechner/pi-tui";
8
+ import { formatLocation } from "./prompt.ts";
9
+ import type {
10
+ DiffRenderMode,
11
+ ReviewComment,
12
+ ReviewLayout,
13
+ ReviewLine,
14
+ ReviewResult,
15
+ ReviewTheme,
16
+ ReviewTui,
17
+ SelectionBounds,
18
+ SplitDiffCell,
19
+ SplitDiffRow,
20
+ } from "./types.ts";
21
+
22
+ function padToWidth(text: string, width: number): string {
23
+ const visible = visibleWidth(text);
24
+ if (visible >= width) return truncateToWidth(text, width);
25
+ return text + " ".repeat(width - visible);
26
+ }
27
+
28
+ function lineNumberCell(value?: number): string {
29
+ return value == null ? " " : String(value).padStart(4, " ");
30
+ }
31
+
32
+ export class ReviewComponent {
33
+ private selected = 0;
34
+ private scrollTop = 0;
35
+ private editMode = false;
36
+ private editingCommentKey?: string;
37
+ private selectionAnchor?: number;
38
+ private layout: ReviewLayout = "side-by-side";
39
+ private diffRenderMode: DiffRenderMode = "unified";
40
+ private editor: Editor;
41
+
42
+ constructor(
43
+ private tui: ReviewTui,
44
+ private theme: ReviewTheme,
45
+ private title: string,
46
+ private lines: ReviewLine[],
47
+ private comments: Map<string, ReviewComment>,
48
+ private done: (result: ReviewResult) => void,
49
+ ) {
50
+ const firstCommentable = this.lines.findIndex((line) => line.commentable);
51
+ this.selected = firstCommentable >= 0 ? firstCommentable : 0;
52
+
53
+ this.editor = new Editor(tui as never, {
54
+ borderColor: (s) => theme.fg("accent", s),
55
+ selectList: {
56
+ selectedPrefix: (t) => theme.fg("accent", t),
57
+ selectedText: (t) => theme.fg("accent", t),
58
+ description: (t) => theme.fg("muted", t),
59
+ scrollInfo: (t) => theme.fg("dim", t),
60
+ noMatch: (t) => theme.fg("warning", t),
61
+ },
62
+ });
63
+
64
+ this.editor.onSubmit = (value) => {
65
+ const selection = this.getActiveCommentSelection();
66
+ if (!selection) {
67
+ this.exitEditMode();
68
+ return;
69
+ }
70
+
71
+ const trimmed = value.trim();
72
+ const key = this.getSelectionKey(selection.start, selection.end);
73
+ if (!trimmed) {
74
+ this.comments.delete(key);
75
+ } else {
76
+ this.comments.set(
77
+ key,
78
+ this.buildCommentFromSelection(selection, trimmed),
79
+ );
80
+ }
81
+
82
+ this.exitEditMode();
83
+ };
84
+ }
85
+
86
+ handleInput(data: string): void {
87
+ if (this.editMode) {
88
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
89
+ this.exitEditMode();
90
+ return;
91
+ }
92
+ this.editor.handleInput(data);
93
+ this.tui.requestRender();
94
+ return;
95
+ }
96
+
97
+ if (matchesKey(data, "escape")) {
98
+ if (this.hasSelection()) {
99
+ this.clearSelection();
100
+ } else {
101
+ this.done({ action: "cancel" });
102
+ }
103
+ return;
104
+ }
105
+ if (data === "q") {
106
+ this.done({ action: "cancel" });
107
+ return;
108
+ }
109
+ if (data === "t") {
110
+ this.toggleDiffRenderMode();
111
+ return;
112
+ }
113
+ if (matchesKey(data, "ctrl+d")) {
114
+ this.move(this.getPageMoveAmount());
115
+ return;
116
+ }
117
+ if (matchesKey(data, "ctrl+u")) {
118
+ this.move(-this.getPageMoveAmount());
119
+ return;
120
+ }
121
+ if (data === "j" || matchesKey(data, "down")) {
122
+ this.move(1);
123
+ return;
124
+ }
125
+ if (data === "k" || matchesKey(data, "up")) {
126
+ this.move(-1);
127
+ return;
128
+ }
129
+ if (data === "J") {
130
+ this.extendSelection(1);
131
+ return;
132
+ }
133
+ if (data === "K") {
134
+ this.extendSelection(-1);
135
+ return;
136
+ }
137
+ if (data === "n") {
138
+ this.jumpHunk(1);
139
+ return;
140
+ }
141
+ if (data === "p") {
142
+ this.jumpHunk(-1);
143
+ return;
144
+ }
145
+ if (data === "x") {
146
+ this.deleteComment();
147
+ return;
148
+ }
149
+ if (data === "c") {
150
+ this.startEditMode();
151
+ return;
152
+ }
153
+ if (data === "R") {
154
+ const comments = [...this.comments.values()].sort((a, b) =>
155
+ a.id.localeCompare(b.id),
156
+ );
157
+ if (comments.length === 0) return;
158
+ this.done({ action: "submit", comments });
159
+ }
160
+ }
161
+
162
+ render(width: number): string[] {
163
+ const viewportHeight = this.getContentHeight();
164
+ const selectedLine = this.lines[this.selected];
165
+ const output: string[] = [];
166
+
167
+ output.push(
168
+ truncateToWidth(
169
+ this.theme.fg(
170
+ "dim",
171
+ this.editMode
172
+ ? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
173
+ : this.hasSelection()
174
+ ? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • R submit`
175
+ : `${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`,
176
+ ),
177
+ width,
178
+ ),
179
+ );
180
+ if (this.layout === "side-by-side") {
181
+ this.ensureScroll(viewportHeight);
182
+ output.push(
183
+ ...this.renderSideBySide(width, viewportHeight, selectedLine),
184
+ );
185
+ } else {
186
+ const { diffHeight, commentsHeight } =
187
+ this.getStackedHeights(viewportHeight);
188
+ this.ensureScroll(diffHeight);
189
+ output.push(
190
+ ...this.renderStacked(width, diffHeight, commentsHeight, selectedLine),
191
+ );
192
+ }
193
+
194
+ output.push(
195
+ truncateToWidth(
196
+ this.theme.fg("muted", this.getFooterText(selectedLine)),
197
+ width,
198
+ ),
199
+ );
200
+ return output;
201
+ }
202
+
203
+ private renderSideBySide(
204
+ width: number,
205
+ height: number,
206
+ selectedLine?: ReviewLine,
207
+ ): string[] {
208
+ const rightWidth = Math.max(28, Math.floor(width * 0.34));
209
+ const separatorWidth = 3;
210
+ const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
211
+ const rightPane = this.renderRightPane(rightWidth, height, selectedLine);
212
+ const output: string[] = [];
213
+ const diffPane = this.renderDiffRows(leftWidth, height);
214
+
215
+ for (let row = 0; row < height; row++) {
216
+ const left = diffPane[row] ?? " ".repeat(leftWidth);
217
+ const right = rightPane[row] ?? " ".repeat(rightWidth);
218
+ const combined = `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`;
219
+ output.push(truncateToWidth(combined, width));
220
+ }
221
+
222
+ return output;
223
+ }
224
+
225
+ private renderStacked(
226
+ width: number,
227
+ diffHeight: number,
228
+ commentsHeight: number,
229
+ selectedLine?: ReviewLine,
230
+ ): string[] {
231
+ const comments = this.renderRightPane(width, commentsHeight, selectedLine);
232
+ return [
233
+ ...this.renderDiffRows(width, diffHeight),
234
+ this.theme.fg("borderMuted", "─".repeat(width)),
235
+ ...Array.from({ length: commentsHeight }, (_, index) =>
236
+ padToWidth(truncateToWidth(comments[index] ?? "", width), width),
237
+ ),
238
+ ];
239
+ }
240
+
241
+ private renderDiffRows(width: number, height: number): string[] {
242
+ return this.diffRenderMode === "split"
243
+ ? this.renderSplitDiffRows(width, height)
244
+ : this.renderUnifiedDiffRows(width, height);
245
+ }
246
+
247
+ private renderUnifiedDiffRows(width: number, height: number): string[] {
248
+ const output: string[] = [];
249
+ const selection = this.getSelectionBounds();
250
+
251
+ for (let row = 0; row < height; row++) {
252
+ const index = this.scrollTop + row;
253
+ const line = this.lines[index];
254
+ output.push(
255
+ line
256
+ ? this.renderDiffLine(
257
+ line,
258
+ index,
259
+ width,
260
+ index === this.selected,
261
+ selection,
262
+ )
263
+ : " ".repeat(width),
264
+ );
265
+ }
266
+
267
+ return output;
268
+ }
269
+
270
+ private renderSplitDiffRows(width: number, height: number): string[] {
271
+ const rows = this.buildSplitDiffRows();
272
+ const output: string[] = [];
273
+ const separatorWidth = 3;
274
+ const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
275
+ const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
276
+
277
+ for (let row = 0; row < height; row++) {
278
+ const splitRow = rows[this.scrollTop + row];
279
+ if (!splitRow) {
280
+ output.push(" ".repeat(width));
281
+ continue;
282
+ }
283
+
284
+ if (splitRow.kind === "full") {
285
+ output.push(
286
+ this.renderDiffLine(
287
+ splitRow.cell.line,
288
+ splitRow.cell.index,
289
+ width,
290
+ splitRow.cell.index === this.selected,
291
+ this.getSelectionBounds(),
292
+ ),
293
+ );
294
+ continue;
295
+ }
296
+
297
+ const left = splitRow.left
298
+ ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
299
+ : " ".repeat(leftWidth);
300
+ const right = splitRow.right
301
+ ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
302
+ : " ".repeat(rightWidth);
303
+ output.push(
304
+ truncateToWidth(
305
+ `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
306
+ width,
307
+ ),
308
+ );
309
+ }
310
+
311
+ return output;
312
+ }
313
+
314
+ private getContentHeight(): number {
315
+ const terminalRows = this.tui.terminal?.rows ?? 24;
316
+ const headerHeight = 3;
317
+ const footerHeight = 2;
318
+ return Math.max(6, terminalRows - headerHeight - footerHeight);
319
+ }
320
+
321
+ private getStackedHeights(viewportHeight: number): {
322
+ diffHeight: number;
323
+ commentsHeight: number;
324
+ } {
325
+ const availableForPanes = Math.max(2, viewportHeight - 1);
326
+ let diffHeight = Math.max(1, Math.floor(availableForPanes * 0.6));
327
+ let commentsHeight = availableForPanes - diffHeight;
328
+
329
+ if (commentsHeight < 3 && availableForPanes >= 4) {
330
+ commentsHeight = 3;
331
+ diffHeight = availableForPanes - commentsHeight;
332
+ }
333
+
334
+ return { diffHeight, commentsHeight };
335
+ }
336
+
337
+ invalidate(): void {}
338
+
339
+ private move(delta: number): void {
340
+ this.selected = Math.max(
341
+ 0,
342
+ Math.min(this.lines.length - 1, this.selected + delta),
343
+ );
344
+ this.tui.requestRender();
345
+ }
346
+
347
+ private toggleLayout(): void {
348
+ this.layout = this.layout === "side-by-side" ? "stacked" : "side-by-side";
349
+ this.tui.requestRender(true);
350
+ }
351
+
352
+ private toggleDiffRenderMode(): void {
353
+ this.diffRenderMode =
354
+ this.diffRenderMode === "unified" ? "split" : "unified";
355
+ this.scrollTop = 0;
356
+ this.tui.requestRender(true);
357
+ }
358
+
359
+ private getPageMoveAmount(): number {
360
+ const contentHeight = this.getContentHeight();
361
+ const diffHeight =
362
+ this.layout === "stacked"
363
+ ? this.getStackedHeights(contentHeight).diffHeight
364
+ : contentHeight;
365
+ return Math.max(1, Math.floor(diffHeight / 2));
366
+ }
367
+
368
+ private extendSelection(delta: number): void {
369
+ if (this.selectionAnchor == null) {
370
+ this.selectionAnchor = this.selected;
371
+ }
372
+ this.selected = Math.max(
373
+ 0,
374
+ Math.min(this.lines.length - 1, this.selected + delta),
375
+ );
376
+ this.tui.requestRender();
377
+ }
378
+
379
+ private clearSelection(): void {
380
+ this.selectionAnchor = undefined;
381
+ this.tui.requestRender();
382
+ }
383
+
384
+ private hasSelection(): boolean {
385
+ return (
386
+ this.selectionAnchor != null && this.selectionAnchor !== this.selected
387
+ );
388
+ }
389
+
390
+ private getSelectionBounds(): SelectionBounds | undefined {
391
+ if (this.selectionAnchor == null) return undefined;
392
+ return {
393
+ start: Math.min(this.selectionAnchor, this.selected),
394
+ end: Math.max(this.selectionAnchor, this.selected),
395
+ };
396
+ }
397
+
398
+ private getActiveCommentSelection(): SelectionBounds | undefined {
399
+ const selection = this.getSelectionBounds();
400
+ if (selection) return selection;
401
+ const line = this.lines[this.selected];
402
+ if (!line?.commentable) return undefined;
403
+ return { start: this.selected, end: this.selected };
404
+ }
405
+
406
+ private getSelectionKey(start: number, end: number): string {
407
+ return `${this.lines[start]?.id ?? start}:${this.lines[end]?.id ?? end}`;
408
+ }
409
+
410
+ private getCommentForSelection(
411
+ selection: SelectionBounds | undefined,
412
+ ): ReviewComment | undefined {
413
+ if (!selection) return undefined;
414
+ return this.comments.get(
415
+ this.getSelectionKey(selection.start, selection.end),
416
+ );
417
+ }
418
+
419
+ private getCommentKeysForLine(index: number): string[] {
420
+ const line = this.lines[index];
421
+ if (!line) return [];
422
+ return [...this.comments.entries()]
423
+ .filter(([, comment]) => {
424
+ const start = this.lines.findIndex(
425
+ (item) => item.id === comment.startLineId,
426
+ );
427
+ const end = this.lines.findIndex(
428
+ (item) => item.id === comment.endLineId,
429
+ );
430
+ return start !== -1 && end !== -1 && index >= start && index <= end;
431
+ })
432
+ .map(([key]) => key);
433
+ }
434
+
435
+ private buildCommentFromSelection(
436
+ selection: SelectionBounds,
437
+ text: string,
438
+ ): ReviewComment {
439
+ const startLine = this.lines[selection.start]!;
440
+ const endLine = this.lines[selection.end]!;
441
+ const excerpt = this.lines
442
+ .slice(selection.start, selection.end + 1)
443
+ .map((line) => line.text)
444
+ .join("\n");
445
+ return {
446
+ id: this.getSelectionKey(selection.start, selection.end),
447
+ filePath: startLine.filePath ?? endLine.filePath ?? "(unknown file)",
448
+ text,
449
+ startLineId: startLine.id,
450
+ endLineId: endLine.id,
451
+ startOldLineNumber: startLine.oldLineNumber,
452
+ startNewLineNumber: startLine.newLineNumber,
453
+ endOldLineNumber: endLine.oldLineNumber,
454
+ endNewLineNumber: endLine.newLineNumber,
455
+ lineText: excerpt,
456
+ };
457
+ }
458
+
459
+ private getFooterText(selectedLine?: ReviewLine): string {
460
+ const selection = this.getSelectionBounds();
461
+ if (selection) {
462
+ const count = selection.end - selection.start + 1;
463
+ const startLine = this.lines[selection.start]!;
464
+ const endLine = this.lines[selection.end]!;
465
+ return `Selected ${count} lines: ${formatLocation(startLine)} -> ${formatLocation(endLine)}`;
466
+ }
467
+ return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
468
+ }
469
+
470
+ private jumpHunk(direction: 1 | -1): void {
471
+ let index = this.selected + direction;
472
+ while (index >= 0 && index < this.lines.length) {
473
+ if (this.lines[index]?.kind === "hunk") {
474
+ this.selected = index;
475
+ this.tui.requestRender();
476
+ return;
477
+ }
478
+ index += direction;
479
+ }
480
+ }
481
+
482
+ private deleteComment(): void {
483
+ const selection = this.getActiveCommentSelection();
484
+ if (!selection) return;
485
+ this.comments.delete(this.getSelectionKey(selection.start, selection.end));
486
+ this.tui.requestRender();
487
+ }
488
+
489
+ private startEditMode(): void {
490
+ const selection = this.getActiveCommentSelection();
491
+ if (!selection) return;
492
+ const startLine = this.lines[selection.start];
493
+ const endLine = this.lines[selection.end];
494
+ if (!startLine?.commentable || !endLine?.commentable) return;
495
+ if (startLine.filePath !== endLine.filePath) return;
496
+
497
+ const existing = this.getCommentForSelection(selection);
498
+ this.editMode = true;
499
+ this.editingCommentKey = this.getSelectionKey(
500
+ selection.start,
501
+ selection.end,
502
+ );
503
+ this.editor.setText(existing?.text ?? "");
504
+ this.tui.requestRender(true);
505
+ }
506
+
507
+ private exitEditMode(): void {
508
+ this.editMode = false;
509
+ this.editingCommentKey = undefined;
510
+ this.editor.setText("");
511
+ this.tui.requestRender(true);
512
+ }
513
+
514
+ private buildSplitDiffRows(): SplitDiffRow[] {
515
+ const rows: SplitDiffRow[] = [];
516
+ let index = 0;
517
+
518
+ while (index < this.lines.length) {
519
+ const line = this.lines[index]!;
520
+
521
+ if (line.kind === "remove" || line.kind === "add") {
522
+ const removals: SplitDiffCell[] = [];
523
+ const additions: SplitDiffCell[] = [];
524
+
525
+ while (this.lines[index]?.kind === "remove") {
526
+ removals.push({ line: this.lines[index]!, index });
527
+ index++;
528
+ }
529
+ while (this.lines[index]?.kind === "add") {
530
+ additions.push({ line: this.lines[index]!, index });
531
+ index++;
532
+ }
533
+
534
+ const count = Math.max(removals.length, additions.length);
535
+ for (let offset = 0; offset < count; offset++) {
536
+ rows.push({
537
+ kind: "split",
538
+ left: removals[offset],
539
+ right: additions[offset],
540
+ });
541
+ }
542
+ continue;
543
+ }
544
+
545
+ if (line.kind === "context") {
546
+ const cell = { line, index };
547
+ rows.push({ kind: "split", left: cell, right: cell });
548
+ } else {
549
+ rows.push({ kind: "full", cell: { line, index } });
550
+ }
551
+ index++;
552
+ }
553
+
554
+ return rows;
555
+ }
556
+
557
+ private getSelectedDisplayRow(): number {
558
+ if (this.diffRenderMode === "unified") return this.selected;
559
+ const rows = this.buildSplitDiffRows();
560
+ const row = rows.findIndex((item) =>
561
+ item.kind === "full"
562
+ ? item.cell.index === this.selected
563
+ : item.left?.index === this.selected ||
564
+ item.right?.index === this.selected,
565
+ );
566
+ return row === -1 ? 0 : row;
567
+ }
568
+
569
+ private getDisplayRowCount(): number {
570
+ return this.diffRenderMode === "unified"
571
+ ? this.lines.length
572
+ : this.buildSplitDiffRows().length;
573
+ }
574
+
575
+ private renderSplitDiffCell(
576
+ cell: SplitDiffCell,
577
+ width: number,
578
+ side: "left" | "right",
579
+ ): string {
580
+ const { line, index } = cell;
581
+ const hasComment = this.getCommentKeysForLine(index).length > 0;
582
+ const commentMark = hasComment ? this.theme.fg("warning", "●") : " ";
583
+ const lineNumber =
584
+ side === "left" ? line.oldLineNumber : line.newLineNumber;
585
+ const raw = `${commentMark} ${lineNumberCell(lineNumber)} ${this.getDisplayText(line)}`;
586
+
587
+ let styled: string;
588
+ switch (line.kind) {
589
+ case "add":
590
+ styled = this.theme.fg("toolDiffAdded", raw);
591
+ break;
592
+ case "remove":
593
+ styled = this.theme.fg("toolDiffRemoved", raw);
594
+ break;
595
+ case "context":
596
+ styled = this.theme.fg("toolDiffContext", raw);
597
+ break;
598
+ default:
599
+ styled = this.theme.fg("muted", raw);
600
+ }
601
+
602
+ styled = truncateToWidth(styled, width);
603
+ const selection = this.getSelectionBounds();
604
+ const inSelection =
605
+ selection != null && index >= selection.start && index <= selection.end;
606
+ if (index === this.selected || inSelection) {
607
+ return this.theme.bg("selectedBg", padToWidth(styled, width));
608
+ }
609
+ return styled;
610
+ }
611
+
612
+ private ensureScroll(viewportHeight: number): void {
613
+ const selectedRow = this.getSelectedDisplayRow();
614
+ const rowCount = this.getDisplayRowCount();
615
+
616
+ if (selectedRow < this.scrollTop) {
617
+ this.scrollTop = selectedRow;
618
+ }
619
+ if (selectedRow >= this.scrollTop + viewportHeight) {
620
+ this.scrollTop = selectedRow - viewportHeight + 1;
621
+ }
622
+ this.scrollTop = Math.max(
623
+ 0,
624
+ Math.min(this.scrollTop, Math.max(0, rowCount - viewportHeight)),
625
+ );
626
+ }
627
+
628
+ private getDisplayText(line: ReviewLine): string {
629
+ return line.kind === "add" ||
630
+ line.kind === "remove" ||
631
+ line.kind === "context"
632
+ ? line.text.slice(1)
633
+ : line.text;
634
+ }
635
+
636
+ private renderDiffLine(
637
+ line: ReviewLine,
638
+ index: number,
639
+ width: number,
640
+ selected: boolean,
641
+ selection?: SelectionBounds,
642
+ ): string {
643
+ const hasComment = this.getCommentKeysForLine(index).length > 0;
644
+ const commentMark = hasComment ? this.theme.fg("warning", "●") : " ";
645
+ const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
646
+ const raw = `${commentMark} ${numbers} ${this.getDisplayText(line)}`;
647
+
648
+ let styled: string;
649
+ switch (line.kind) {
650
+ case "add":
651
+ styled = this.theme.fg("toolDiffAdded", raw);
652
+ break;
653
+ case "remove":
654
+ styled = this.theme.fg("toolDiffRemoved", raw);
655
+ break;
656
+ case "context":
657
+ styled = this.theme.fg("toolDiffContext", raw);
658
+ break;
659
+ case "hunk":
660
+ styled = this.theme.fg("accent", raw);
661
+ break;
662
+ default:
663
+ styled = this.theme.fg("muted", raw);
664
+ }
665
+
666
+ styled = truncateToWidth(styled, width);
667
+ const inSelection =
668
+ selection != null && index >= selection.start && index <= selection.end;
669
+ if (selected || inSelection) {
670
+ return this.theme.bg("selectedBg", padToWidth(styled, width));
671
+ }
672
+ return styled;
673
+ }
674
+
675
+ private renderRightPane(
676
+ width: number,
677
+ height: number,
678
+ selectedLine?: ReviewLine,
679
+ ): string[] {
680
+ const lines: string[] = [];
681
+ const title = this.theme.fg("accent", this.theme.bold("Comments"));
682
+ const selection = this.getActiveCommentSelection();
683
+ const currentComment = this.getCommentForSelection(selection);
684
+
685
+ lines.push(truncateToWidth(title, width));
686
+ lines.push(
687
+ truncateToWidth(
688
+ this.theme.fg(
689
+ "dim",
690
+ selection
691
+ ? this.getFooterText(selectedLine)
692
+ : selectedLine
693
+ ? formatLocation(selectedLine)
694
+ : "No selection",
695
+ ),
696
+ width,
697
+ ),
698
+ );
699
+ lines.push("");
700
+
701
+ if (!selectedLine) {
702
+ lines.push(
703
+ ...wrapTextWithAnsi(
704
+ this.theme.fg("muted", "No diff lines available."),
705
+ width,
706
+ ),
707
+ );
708
+ return lines.slice(0, height);
709
+ }
710
+
711
+ if (!selection) {
712
+ lines.push(
713
+ ...wrapTextWithAnsi(
714
+ this.theme.fg(
715
+ "muted",
716
+ "Move to a diff line and press c to add a comment.",
717
+ ),
718
+ width,
719
+ ),
720
+ );
721
+ return lines.slice(0, height);
722
+ }
723
+
724
+ if (this.editMode && currentComment?.id === this.editingCommentKey) {
725
+ lines.push(
726
+ ...wrapTextWithAnsi(
727
+ this.theme.fg(
728
+ "dim",
729
+ "Editing comment. Enter saves. Esc or Ctrl+C cancels.",
730
+ ),
731
+ width,
732
+ ),
733
+ );
734
+ lines.push("");
735
+ for (const line of this.editor.render(Math.max(10, width))) {
736
+ lines.push(truncateToWidth(line, width));
737
+ }
738
+ } else if (this.editMode && this.editingCommentKey) {
739
+ lines.push(
740
+ ...wrapTextWithAnsi(
741
+ this.theme.fg(
742
+ "dim",
743
+ "Editing comment. Enter saves. Esc or Ctrl+C cancels.",
744
+ ),
745
+ width,
746
+ ),
747
+ );
748
+ lines.push("");
749
+ for (const line of this.editor.render(Math.max(10, width))) {
750
+ lines.push(truncateToWidth(line, width));
751
+ }
752
+ } else if (currentComment) {
753
+ lines.push(
754
+ ...wrapTextWithAnsi(this.theme.fg("text", currentComment.text), width),
755
+ );
756
+ lines.push("");
757
+ lines.push(
758
+ ...wrapTextWithAnsi(
759
+ this.theme.fg("dim", "x deletes this comment • c edits"),
760
+ width,
761
+ ),
762
+ );
763
+ } else {
764
+ lines.push(
765
+ ...wrapTextWithAnsi(
766
+ this.theme.fg(
767
+ "muted",
768
+ this.hasSelection()
769
+ ? "No comment on this range."
770
+ : "No comment on this line.",
771
+ ),
772
+ width,
773
+ ),
774
+ );
775
+ lines.push("");
776
+ lines.push(
777
+ ...wrapTextWithAnsi(
778
+ this.theme.fg(
779
+ "dim",
780
+ this.hasSelection()
781
+ ? "Press c to add a range comment."
782
+ : "Press c to add one. Use J/K to extend a range.",
783
+ ),
784
+ width,
785
+ ),
786
+ );
787
+ }
788
+
789
+ lines.push("");
790
+ lines.push(
791
+ truncateToWidth(
792
+ this.theme.fg("accent", this.theme.bold("Excerpt")),
793
+ width,
794
+ ),
795
+ );
796
+ const excerpt = this.lines
797
+ .slice(selection.start, selection.end + 1)
798
+ .map((line) => line.text)
799
+ .join("\n");
800
+ lines.push(
801
+ ...wrapTextWithAnsi(
802
+ this.theme.fg("toolDiffContext", excerpt || "(blank line)"),
803
+ width,
804
+ ),
805
+ );
806
+ return lines.slice(0, height);
807
+ }
808
+ }