pi-diff-review 0.1.14 → 0.1.16

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
@@ -68,6 +68,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
68
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
@@ -78,21 +79,6 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
78
79
  - Comments are cached per session and restored when reopening the same diff
79
80
  - `q` to exit
80
81
 
81
- ## Development
82
+ ## Contributing
82
83
 
83
- Install [pre-commit](https://pre-commit.com/) and enable the repository hooks to run typechecking, tests, and formatting checks before each commit:
84
-
85
- ```bash
86
- pre-commit install
87
- ```
88
-
89
- Run the same checks manually with either:
90
-
91
- ```bash
92
- pre-commit run --all-files
93
- npm run precommit
94
- ```
95
-
96
- ## Release
97
-
98
- See [RELEASE.md](./RELEASE.md).
84
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and release instructions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,7 +5,7 @@ import type {
5
5
  FileDiffMetadata,
6
6
  Hunk,
7
7
  } from "@pierre/diffs";
8
- import type { ReviewLine } from "./types.ts";
8
+ import type { ReviewLine } from "../review/types.ts";
9
9
 
10
10
  export function parseDiff(diffText: string): ReviewLine[] {
11
11
  try {
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import type { DiffSource } from "./types.ts";
2
+ import type { DiffSource } from "../review/types.ts";
3
3
 
4
4
  export function parseDiffSource(args: string): DiffSource {
5
5
  const trimmed = args.trim();
@@ -1,4 +1,8 @@
1
- import type { ReviewLine, SplitDiffCell, SplitDiffRow } from "./types.ts";
1
+ import type {
2
+ ReviewLine,
3
+ SplitDiffCell,
4
+ SplitDiffRow,
5
+ } from "../review/types.ts";
2
6
 
3
7
  export type SplitDiffRowsResult = {
4
8
  rows: SplitDiffRow[];
@@ -2,8 +2,8 @@ import type {
2
2
  DiffExplainer,
3
3
  ExplanationScope,
4
4
  ExplanationState,
5
- } from "./explain.ts";
6
- import type { ReviewLine, ReviewTui } from "./types.ts";
5
+ } from "./explainer.ts";
6
+ import type { ReviewLine, ReviewTui } from "../review/types.ts";
7
7
 
8
8
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
9
 
package/src/index.ts CHANGED
@@ -3,12 +3,16 @@ import type {
3
3
  ExtensionAPI,
4
4
  ExtensionCommandContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { getDiff, parseDiffSource } from "./diff-source.ts";
7
- import { parseDiff } from "./diff-parser.ts";
8
- import { PiModelDiffExplainer } from "./explain.ts";
9
- import { buildReviewPrompt } from "./prompt.ts";
10
- import { ReviewComponent } from "./review-component.ts";
11
- import type { DiffSource, ReviewComment, ReviewResult } from "./types.ts";
6
+ import { getDiff, parseDiffSource } from "./diff/source.ts";
7
+ import { parseDiff } from "./diff/parser.ts";
8
+ import { PiModelDiffExplainer } from "./explanation/explainer.ts";
9
+ import { buildReviewPrompt } from "./review/prompt.ts";
10
+ import { ReviewComponent } from "./review/component.ts";
11
+ import type {
12
+ DiffSource,
13
+ ReviewComment,
14
+ ReviewResult,
15
+ } from "./review/types.ts";
12
16
 
13
17
  const DIFF_REVIEW_CACHE_ENTRY = "pi-diff-review-cache";
14
18
  const DIFF_REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
@@ -9,22 +9,23 @@ import {
9
9
  visibleWidth,
10
10
  wrapTextWithAnsi,
11
11
  } from "@earendil-works/pi-tui";
12
- import type { DiffExplainer } from "./explain.ts";
12
+ import type { DiffExplainer } from "../explanation/explainer.ts";
13
13
  import {
14
14
  GLOBAL_COMMENT_KEY,
15
15
  buildCommentFromSelection,
16
16
  buildCommentLineKeys,
17
17
  buildGlobalComment,
18
18
  getSelectionKey,
19
- } from "./comment-manager.ts";
19
+ } from "./comments.ts";
20
20
  import {
21
21
  ExplanationController,
22
22
  getCurrentHunkScope,
23
- } from "./explanation-controller.ts";
23
+ } from "../explanation/controller.ts";
24
24
  import { formatCommentLocation, formatLocation } from "./prompt.ts";
25
- import { ReviewNavigationState } from "./review-navigation.ts";
26
- import { padToWidth, lineNumberCell } from "./render-utils.ts";
27
- import { buildSplitDiffRows } from "./split-diff.ts";
25
+ import { ReviewNavigationState } from "./navigation.ts";
26
+ import { ReviewSearchState } from "./search.ts";
27
+ import { padToWidth, lineNumberCell } from "../render/utils.ts";
28
+ import { buildSplitDiffRows } from "../diff/split.ts";
28
29
  import type {
29
30
  DiffRenderMode,
30
31
  ReviewComment,
@@ -54,6 +55,7 @@ export class ReviewComponent {
54
55
  private navigation: ReviewNavigationState;
55
56
  private editMode = false;
56
57
  private editingCommentKey?: string;
58
+ private search: ReviewSearchState;
57
59
 
58
60
  private inlineAnnotationsVisible = true;
59
61
  private visibleExplanationKeys = new Set<string>();
@@ -93,6 +95,7 @@ export class ReviewComponent {
93
95
  firstCommentable >= 0 ? firstCommentable : 0,
94
96
  );
95
97
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
98
+ this.search = new ReviewSearchState(this.lines);
96
99
  this.explanationController = new ExplanationController(
97
100
  tui,
98
101
  explainer,
@@ -179,8 +182,15 @@ export class ReviewComponent {
179
182
  return;
180
183
  }
181
184
 
185
+ if (this.search.mode) {
186
+ this.handleSearchInput(data);
187
+ return;
188
+ }
189
+
182
190
  if (matchesKey(data, "escape")) {
183
- if (this.hasSelection()) {
191
+ if (this.search.query) {
192
+ this.clearSearch();
193
+ } else if (this.hasSelection()) {
184
194
  this.clearSelection();
185
195
  } else {
186
196
  this.done({ action: "cancel" });
@@ -203,6 +213,10 @@ export class ReviewComponent {
203
213
  this.toggleExplanationPane();
204
214
  return;
205
215
  }
216
+ if (data === "/") {
217
+ this.startSearchMode();
218
+ return;
219
+ }
206
220
  if (matchesKey(data, "ctrl+d")) {
207
221
  this.move(this.getPageMoveAmount());
208
222
  return;
@@ -236,7 +250,15 @@ export class ReviewComponent {
236
250
  return;
237
251
  }
238
252
  if (data === "n") {
239
- this.jumpHunk(1);
253
+ if (this.search.query) {
254
+ this.jumpSearch(1);
255
+ } else {
256
+ this.jumpHunk(1);
257
+ }
258
+ return;
259
+ }
260
+ if (data === "N" && this.search.query) {
261
+ this.jumpSearch(-1);
240
262
  return;
241
263
  }
242
264
  if (data === "p") {
@@ -277,7 +299,7 @@ export class ReviewComponent {
277
299
  ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
278
300
  : this.hasSelection()
279
301
  ? `${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`,
302
+ : `${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.search.query ? "n/N search" : "n/p hunk"} • Enter submit • q quit`,
281
303
  ),
282
304
  width,
283
305
  ),
@@ -846,6 +868,13 @@ export class ReviewComponent {
846
868
  }
847
869
 
848
870
  private getFooterText(selectedLine?: ReviewLine): string {
871
+ if (this.search.mode) {
872
+ return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
873
+ }
874
+
875
+ const searchStatus = this.search.getStatusText(this.selected);
876
+ if (searchStatus) return searchStatus;
877
+
849
878
  const selection = this.getSelectionBounds();
850
879
  if (selection) {
851
880
  const count = selection.end - selection.start + 1;
@@ -919,6 +948,28 @@ export class ReviewComponent {
919
948
  this.tui.requestRender(true);
920
949
  }
921
950
 
951
+ private startSearchMode(): void {
952
+ this.search.start();
953
+ this.tui.requestRender();
954
+ }
955
+
956
+ private handleSearchInput(data: string): void {
957
+ const result = this.search.handleInput(data, this.selected);
958
+ if (result.selected != null) this.selected = result.selected;
959
+ this.tui.requestRender();
960
+ }
961
+
962
+ private clearSearch(): void {
963
+ this.search.clear();
964
+ this.tui.requestRender();
965
+ }
966
+
967
+ private jumpSearch(direction: 1 | -1): void {
968
+ const result = this.search.jump(direction, this.selected);
969
+ if (result.selected != null) this.selected = result.selected;
970
+ this.tui.requestRender();
971
+ }
972
+
922
973
  private getSplitDiffRows(): SplitDiffRow[] {
923
974
  if (this.splitRows) return this.splitRows;
924
975
 
@@ -968,11 +1019,11 @@ export class ReviewComponent {
968
1019
  }
969
1020
 
970
1021
  private getDisplayText(line: ReviewLine): string {
971
- return line.kind === "add" ||
972
- line.kind === "remove" ||
973
- line.kind === "context"
974
- ? line.text.slice(1)
975
- : line.text;
1022
+ const raw =
1023
+ line.kind === "add" || line.kind === "remove" || line.kind === "context"
1024
+ ? line.text.slice(1)
1025
+ : line.text;
1026
+ return expandTabs(raw);
976
1027
  }
977
1028
 
978
1029
  private applyDiffBackground(
@@ -1054,3 +1105,19 @@ export class ReviewComponent {
1054
1105
  return getCurrentHunkScope(this.lines, this.selected);
1055
1106
  }
1056
1107
  }
1108
+
1109
+ function expandTabs(text: string, tabSize = 4): string {
1110
+ let result = "";
1111
+ let col = 0;
1112
+ for (const char of text) {
1113
+ if (char === "\t") {
1114
+ const spaces = tabSize - (col % tabSize);
1115
+ result += " ".repeat(spaces);
1116
+ col += spaces;
1117
+ } else {
1118
+ result += char;
1119
+ col++;
1120
+ }
1121
+ }
1122
+ return result;
1123
+ }
@@ -0,0 +1,109 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
2
+ import type { ReviewLine } from "./types.ts";
3
+
4
+ export type SearchInputResult = {
5
+ selected?: number;
6
+ };
7
+
8
+ export class ReviewSearchState {
9
+ mode = false;
10
+ query = "";
11
+ draftQuery = "";
12
+ message = "";
13
+
14
+ constructor(private readonly lines: ReviewLine[]) {}
15
+
16
+ start(): void {
17
+ this.mode = true;
18
+ this.draftQuery = this.query;
19
+ this.message = "";
20
+ }
21
+
22
+ handleInput(data: string, selected: number): SearchInputResult {
23
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
24
+ this.mode = false;
25
+ this.draftQuery = "";
26
+ return {};
27
+ }
28
+
29
+ if (matchesKey(data, "enter")) {
30
+ const query = this.draftQuery.trim();
31
+ this.mode = false;
32
+ this.draftQuery = "";
33
+ this.query = query;
34
+ this.message = "";
35
+ return query ? this.jump(1, selected) : {};
36
+ }
37
+
38
+ if (matchesKey(data, "ctrl+u")) {
39
+ this.draftQuery = "";
40
+ return {};
41
+ }
42
+
43
+ if (matchesKey(data, "backspace") || data === "\u007f" || data === "\b") {
44
+ this.draftQuery = this.draftQuery.slice(0, -1);
45
+ return {};
46
+ }
47
+
48
+ if (data.length === 1 && data >= " " && data !== "\u007f") {
49
+ this.draftQuery += data;
50
+ }
51
+
52
+ return {};
53
+ }
54
+
55
+ clear(): void {
56
+ this.mode = false;
57
+ this.query = "";
58
+ this.draftQuery = "";
59
+ this.message = "";
60
+ }
61
+
62
+ jump(direction: 1 | -1, selected: number): SearchInputResult {
63
+ const query = this.query.trim();
64
+ if (!query) return {};
65
+
66
+ const matches = this.getMatchIndexes(query);
67
+ if (matches.length === 0) {
68
+ this.message = `No matches for /${query}`;
69
+ return {};
70
+ }
71
+
72
+ this.message = "";
73
+ const current =
74
+ direction === 1
75
+ ? matches.find((index) => index > selected)
76
+ : [...matches].reverse().find((index) => index < selected);
77
+ return {
78
+ selected:
79
+ current ??
80
+ (direction === 1 ? matches[0]! : matches[matches.length - 1]!),
81
+ };
82
+ }
83
+
84
+ getStatusText(selected: number): string {
85
+ if (this.message) return this.message;
86
+ if (!this.query) return "";
87
+
88
+ const matches = this.getMatchIndexes(this.query);
89
+ if (matches.length === 0) return `No matches for /${this.query}`;
90
+
91
+ const current = matches.findIndex((index) => index === selected);
92
+ const position =
93
+ current >= 0
94
+ ? `${current + 1}/${matches.length}`
95
+ : `${matches.length} matches`;
96
+ return `Search /${this.query} • ${position} • n next • N previous • Esc clear search`;
97
+ }
98
+
99
+ private getMatchIndexes(query: string): number[] {
100
+ const needle = query.toLocaleLowerCase();
101
+ if (!needle) return [];
102
+
103
+ const matches: number[] = [];
104
+ this.lines.forEach((line, index) => {
105
+ if (line.text.toLocaleLowerCase().includes(needle)) matches.push(index);
106
+ });
107
+ return matches;
108
+ }
109
+ }
File without changes
File without changes
File without changes
File without changes
File without changes