pi-diff-review 0.1.15 → 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
@@ -79,21 +79,6 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
79
79
  - Comments are cached per session and restored when reopening the same diff
80
80
  - `q` to exit
81
81
 
82
- ## Development
82
+ ## Contributing
83
83
 
84
- Install [pre-commit](https://pre-commit.com/) and enable the repository hooks to run typechecking, tests, and formatting checks before each commit:
85
-
86
- ```bash
87
- pre-commit install
88
- ```
89
-
90
- Run the same checks manually with either:
91
-
92
- ```bash
93
- pre-commit run --all-files
94
- npm run precommit
95
- ```
96
-
97
- ## Release
98
-
99
- 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.15",
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,10 +55,7 @@ export class ReviewComponent {
54
55
  private navigation: ReviewNavigationState;
55
56
  private editMode = false;
56
57
  private editingCommentKey?: string;
57
- private searchMode = false;
58
- private searchQuery = "";
59
- private draftSearchQuery = "";
60
- private searchMessage = "";
58
+ private search: ReviewSearchState;
61
59
 
62
60
  private inlineAnnotationsVisible = true;
63
61
  private visibleExplanationKeys = new Set<string>();
@@ -97,6 +95,7 @@ export class ReviewComponent {
97
95
  firstCommentable >= 0 ? firstCommentable : 0,
98
96
  );
99
97
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
98
+ this.search = new ReviewSearchState(this.lines);
100
99
  this.explanationController = new ExplanationController(
101
100
  tui,
102
101
  explainer,
@@ -183,13 +182,13 @@ export class ReviewComponent {
183
182
  return;
184
183
  }
185
184
 
186
- if (this.searchMode) {
185
+ if (this.search.mode) {
187
186
  this.handleSearchInput(data);
188
187
  return;
189
188
  }
190
189
 
191
190
  if (matchesKey(data, "escape")) {
192
- if (this.searchQuery) {
191
+ if (this.search.query) {
193
192
  this.clearSearch();
194
193
  } else if (this.hasSelection()) {
195
194
  this.clearSelection();
@@ -251,14 +250,14 @@ export class ReviewComponent {
251
250
  return;
252
251
  }
253
252
  if (data === "n") {
254
- if (this.searchQuery) {
253
+ if (this.search.query) {
255
254
  this.jumpSearch(1);
256
255
  } else {
257
256
  this.jumpHunk(1);
258
257
  }
259
258
  return;
260
259
  }
261
- if (data === "N" && this.searchQuery) {
260
+ if (data === "N" && this.search.query) {
262
261
  this.jumpSearch(-1);
263
262
  return;
264
263
  }
@@ -300,7 +299,7 @@ export class ReviewComponent {
300
299
  ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
301
300
  : this.hasSelection()
302
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`
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`,
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`,
304
303
  ),
305
304
  width,
306
305
  ),
@@ -869,11 +868,11 @@ export class ReviewComponent {
869
868
  }
870
869
 
871
870
  private getFooterText(selectedLine?: ReviewLine): string {
872
- if (this.searchMode) {
873
- return `Search: /${this.draftSearchQuery} • Enter jump • Esc cancel`;
871
+ if (this.search.mode) {
872
+ return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
874
873
  }
875
874
 
876
- const searchStatus = this.getSearchStatusText();
875
+ const searchStatus = this.search.getStatusText(this.selected);
877
876
  if (searchStatus) return searchStatus;
878
877
 
879
878
  const selection = this.getSelectionBounds();
@@ -886,21 +885,6 @@ export class ReviewComponent {
886
885
  return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
887
886
  }
888
887
 
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
-
904
888
  private jumpHunk(direction: 1 | -1): void {
905
889
  let index = this.selected + direction;
906
890
  while (index >= 0 && index < this.lines.length) {
@@ -965,92 +949,27 @@ export class ReviewComponent {
965
949
  }
966
950
 
967
951
  private startSearchMode(): void {
968
- this.searchMode = true;
969
- this.draftSearchQuery = this.searchQuery;
970
- this.searchMessage = "";
952
+ this.search.start();
971
953
  this.tui.requestRender();
972
954
  }
973
955
 
974
956
  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
- }
957
+ const result = this.search.handleInput(data, this.selected);
958
+ if (result.selected != null) this.selected = result.selected;
959
+ this.tui.requestRender();
1012
960
  }
1013
961
 
1014
962
  private clearSearch(): void {
1015
- this.searchMode = false;
1016
- this.searchQuery = "";
1017
- this.draftSearchQuery = "";
1018
- this.searchMessage = "";
963
+ this.search.clear();
1019
964
  this.tui.requestRender();
1020
965
  }
1021
966
 
1022
967
  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]!);
968
+ const result = this.search.jump(direction, this.selected);
969
+ if (result.selected != null) this.selected = result.selected;
1040
970
  this.tui.requestRender();
1041
971
  }
1042
972
 
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
-
1054
973
  private getSplitDiffRows(): SplitDiffRow[] {
1055
974
  if (this.splitRows) return this.splitRows;
1056
975
 
@@ -1100,11 +1019,11 @@ export class ReviewComponent {
1100
1019
  }
1101
1020
 
1102
1021
  private getDisplayText(line: ReviewLine): string {
1103
- return line.kind === "add" ||
1104
- line.kind === "remove" ||
1105
- line.kind === "context"
1106
- ? line.text.slice(1)
1107
- : 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);
1108
1027
  }
1109
1028
 
1110
1029
  private applyDiffBackground(
@@ -1186,3 +1105,19 @@ export class ReviewComponent {
1186
1105
  return getCurrentHunkScope(this.lines, this.selected);
1187
1106
  }
1188
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