pi-diff-review 0.1.15 → 0.1.17

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
@@ -62,6 +62,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
62
62
  - `/diff --cached` reviews staged changes
63
63
  - `/diff main...HEAD` reviews changes on the current branch relative to `main`
64
64
  - `/diff <git-diff-args>` passes arguments through to `git diff`
65
+ - `h` toggles the command help modal
65
66
  - `j/k` or arrow keys to move
66
67
  - `g/G` to jump to the top or bottom of the diff
67
68
  - `ctrl-u` / `ctrl-d` to move up/down by half a page
@@ -79,21 +80,6 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
79
80
  - Comments are cached per session and restored when reopening the same diff
80
81
  - `q` to exit
81
82
 
82
- ## Development
83
+ ## Contributing
83
84
 
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).
85
+ 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.17",
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,
@@ -50,14 +51,32 @@ type AnnotatedDiffRow =
50
51
  | { kind: "split"; splitRowIndex: number }
51
52
  | InlineBoxRow;
52
53
 
54
+ const HELP_COMMANDS = [
55
+ ["h", "show or hide this help"],
56
+ ["j/k or arrows", "move selection"],
57
+ ["ctrl-u / ctrl-d", "move up or down half a page"],
58
+ ["g / G", "jump to top or bottom"],
59
+ ["n / p", "jump to next or previous hunk"],
60
+ ["/", "search diff lines"],
61
+ ["n / N", "jump between search matches"],
62
+ ["J / K", "extend highlighted selection"],
63
+ ["c", "add or edit a line or range comment"],
64
+ ["C", "add or edit an overall diff comment"],
65
+ ["x", "delete the current line or range comment"],
66
+ ["t", "toggle inline comments and explanations"],
67
+ ["v", "toggle unified or split rendering"],
68
+ ["?", "toggle AI explanation for current hunk"],
69
+ ["Enter", "submit comments, save edits, or jump to search result"],
70
+ ["Esc", "close help, cancel search/edit, clear selection, or exit"],
71
+ ["q", "exit review"],
72
+ ] as const;
73
+
53
74
  export class ReviewComponent {
54
75
  private navigation: ReviewNavigationState;
55
76
  private editMode = false;
56
77
  private editingCommentKey?: string;
57
- private searchMode = false;
58
- private searchQuery = "";
59
- private draftSearchQuery = "";
60
- private searchMessage = "";
78
+ private search: ReviewSearchState;
79
+ private helpVisible = false;
61
80
 
62
81
  private inlineAnnotationsVisible = true;
63
82
  private visibleExplanationKeys = new Set<string>();
@@ -97,6 +116,7 @@ export class ReviewComponent {
97
116
  firstCommentable >= 0 ? firstCommentable : 0,
98
117
  );
99
118
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
119
+ this.search = new ReviewSearchState(this.lines);
100
120
  this.explanationController = new ExplanationController(
101
121
  tui,
102
122
  explainer,
@@ -172,6 +192,13 @@ export class ReviewComponent {
172
192
  }
173
193
 
174
194
  handleInput(data: string): void {
195
+ if (this.helpVisible) {
196
+ if (data === "h" || matchesKey(data, "escape")) {
197
+ this.toggleHelp();
198
+ }
199
+ return;
200
+ }
201
+
175
202
  if (this.editMode) {
176
203
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
177
204
  this.exitEditMode();
@@ -183,13 +210,13 @@ export class ReviewComponent {
183
210
  return;
184
211
  }
185
212
 
186
- if (this.searchMode) {
213
+ if (this.search.mode) {
187
214
  this.handleSearchInput(data);
188
215
  return;
189
216
  }
190
217
 
191
218
  if (matchesKey(data, "escape")) {
192
- if (this.searchQuery) {
219
+ if (this.search.query) {
193
220
  this.clearSearch();
194
221
  } else if (this.hasSelection()) {
195
222
  this.clearSelection();
@@ -202,6 +229,10 @@ export class ReviewComponent {
202
229
  this.done({ action: "cancel" });
203
230
  return;
204
231
  }
232
+ if (data === "h") {
233
+ this.toggleHelp();
234
+ return;
235
+ }
205
236
  if (data === "t") {
206
237
  this.toggleInlineAnnotations();
207
238
  return;
@@ -251,14 +282,14 @@ export class ReviewComponent {
251
282
  return;
252
283
  }
253
284
  if (data === "n") {
254
- if (this.searchQuery) {
285
+ if (this.search.query) {
255
286
  this.jumpSearch(1);
256
287
  } else {
257
288
  this.jumpHunk(1);
258
289
  }
259
290
  return;
260
291
  }
261
- if (data === "N" && this.searchQuery) {
292
+ if (data === "N" && this.search.query) {
262
293
  this.jumpSearch(-1);
263
294
  return;
264
295
  }
@@ -293,15 +324,9 @@ export class ReviewComponent {
293
324
  const output: string[] = [];
294
325
 
295
326
  output.push(
296
- truncateToWidth(
297
- this.theme.fg(
298
- "dim",
299
- this.editMode
300
- ? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
301
- : this.hasSelection()
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`,
304
- ),
327
+ this.renderStatusLine(
328
+ this.getHeaderText(selectedLine),
329
+ "Press h for help",
305
330
  width,
306
331
  ),
307
332
  );
@@ -315,9 +340,132 @@ export class ReviewComponent {
315
340
  width,
316
341
  ),
317
342
  );
343
+ return this.helpVisible ? this.renderHelpModal(output, width) : output;
344
+ }
345
+
346
+ private getHeaderText(selectedLine?: ReviewLine): string {
347
+ const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments`;
348
+
349
+ if (this.editMode) {
350
+ return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
351
+ }
352
+
353
+ if (this.hasSelection()) {
354
+ return `${base} • selection active`;
355
+ }
356
+
357
+ return `${base} • ${this.getPositionText(selectedLine)} • inline ${this.inlineAnnotationsVisible ? "shown" : "hidden"} • ${this.diffRenderMode}`;
358
+ }
359
+
360
+ private renderStatusLine(left: string, right: string, width: number): string {
361
+ const styledLeft = this.theme.fg("dim", left);
362
+ const styledRight = this.theme.fg("muted", right);
363
+ const rightWidth = visibleWidth(styledRight);
364
+ const leftWidth = Math.max(0, width - rightWidth - 1);
365
+ const truncatedLeft = truncateToWidth(styledLeft, leftWidth);
366
+ const spacer = Math.max(
367
+ 1,
368
+ width - visibleWidth(truncatedLeft) - rightWidth,
369
+ );
370
+ return truncateToWidth(
371
+ `${truncatedLeft}${" ".repeat(spacer)}${styledRight}`,
372
+ width,
373
+ );
374
+ }
375
+
376
+ private renderHelpModal(rows: string[], width: number): string[] {
377
+ if (rows.length === 0 || width < 8) return rows;
378
+
379
+ const modalWidth = Math.max(8, Math.min(72, width - 2));
380
+ const contentWidth = Math.max(4, modalWidth - 4);
381
+ const keyWidth = Math.min(16, Math.max(8, Math.floor(contentWidth * 0.35)));
382
+ const modalRows = this.buildHelpModalRows(
383
+ modalWidth,
384
+ contentWidth,
385
+ keyWidth,
386
+ );
387
+ const maxRows = Math.max(1, rows.length - 2);
388
+ const visibleModalRows =
389
+ modalRows.length > maxRows
390
+ ? [
391
+ ...modalRows.slice(0, maxRows - 1),
392
+ this.renderModalRow(
393
+ this.theme.fg("dim", "More commands available on taller screens"),
394
+ contentWidth,
395
+ ),
396
+ ]
397
+ : modalRows;
398
+ const top = Math.max(
399
+ 1,
400
+ Math.floor((rows.length - visibleModalRows.length) / 2),
401
+ );
402
+ const left = Math.max(0, Math.floor((width - modalWidth) / 2));
403
+
404
+ const output = [...rows];
405
+ for (let index = 0; index < visibleModalRows.length; index++) {
406
+ const row = visibleModalRows[index]!;
407
+ output[top + index] = padToWidth(
408
+ truncateToWidth(`${" ".repeat(left)}${row}`, width),
409
+ width,
410
+ );
411
+ }
318
412
  return output;
319
413
  }
320
414
 
415
+ private buildHelpModalRows(
416
+ modalWidth: number,
417
+ contentWidth: number,
418
+ keyWidth: number,
419
+ ): string[] {
420
+ const rows = [
421
+ this.renderModalHorizontal(" Help ", modalWidth, "top"),
422
+ this.renderModalRow(
423
+ this.theme.fg("dim", "Press h or Esc to close."),
424
+ contentWidth,
425
+ ),
426
+ this.renderModalRow("", contentWidth),
427
+ ];
428
+
429
+ for (const [keys, description] of HELP_COMMANDS) {
430
+ const keyCell = padToWidth(
431
+ truncateToWidth(this.theme.fg("accent", keys), keyWidth),
432
+ keyWidth,
433
+ );
434
+ rows.push(
435
+ this.renderModalRow(
436
+ `${keyCell} ${this.theme.fg("text", description)}`,
437
+ contentWidth,
438
+ ),
439
+ );
440
+ }
441
+
442
+ rows.push(this.renderModalHorizontal("", modalWidth, "bottom"));
443
+ return rows;
444
+ }
445
+
446
+ private renderModalRow(text: string, contentWidth: number): string {
447
+ return `${this.theme.fg("borderMuted", "│")} ${padToWidth(
448
+ truncateToWidth(text, contentWidth),
449
+ contentWidth,
450
+ )} ${this.theme.fg("borderMuted", "│")}`;
451
+ }
452
+
453
+ private renderModalHorizontal(
454
+ title: string,
455
+ width: number,
456
+ part: "top" | "bottom",
457
+ ): string {
458
+ const contentWidth = Math.max(0, width - 2);
459
+ const visibleTitle = truncateToWidth(title, contentWidth);
460
+ const remaining = Math.max(0, contentWidth - visibleWidth(visibleTitle));
461
+ const left = part === "top" ? "╭" : "╰";
462
+ const right = part === "top" ? "╮" : "╯";
463
+ return `${this.theme.fg("borderMuted", left)}${visibleTitle}${this.theme.fg(
464
+ "borderMuted",
465
+ "─".repeat(remaining),
466
+ )}${this.theme.fg("borderMuted", right)}`;
467
+ }
468
+
321
469
  private renderAnnotatedDiffRows(width: number, height: number): string[] {
322
470
  const rows = this.getAnnotatedRows(width);
323
471
  const output: string[] = [];
@@ -767,6 +915,11 @@ export class ReviewComponent {
767
915
  this.tui.requestRender(true);
768
916
  }
769
917
 
918
+ private toggleHelp(): void {
919
+ this.helpVisible = !this.helpVisible;
920
+ this.tui.requestRender(true);
921
+ }
922
+
770
923
  private hideInlineExplanation(): void {
771
924
  this.visibleExplanationKeys.clear();
772
925
  }
@@ -869,11 +1022,11 @@ export class ReviewComponent {
869
1022
  }
870
1023
 
871
1024
  private getFooterText(selectedLine?: ReviewLine): string {
872
- if (this.searchMode) {
873
- return `Search: /${this.draftSearchQuery} • Enter jump • Esc cancel`;
1025
+ if (this.search.mode) {
1026
+ return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
874
1027
  }
875
1028
 
876
- const searchStatus = this.getSearchStatusText();
1029
+ const searchStatus = this.search.getStatusText(this.selected);
877
1030
  if (searchStatus) return searchStatus;
878
1031
 
879
1032
  const selection = this.getSelectionBounds();
@@ -886,21 +1039,6 @@ export class ReviewComponent {
886
1039
  return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
887
1040
  }
888
1041
 
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
1042
  private jumpHunk(direction: 1 | -1): void {
905
1043
  let index = this.selected + direction;
906
1044
  while (index >= 0 && index < this.lines.length) {
@@ -965,92 +1103,27 @@ export class ReviewComponent {
965
1103
  }
966
1104
 
967
1105
  private startSearchMode(): void {
968
- this.searchMode = true;
969
- this.draftSearchQuery = this.searchQuery;
970
- this.searchMessage = "";
1106
+ this.search.start();
971
1107
  this.tui.requestRender();
972
1108
  }
973
1109
 
974
1110
  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
- }
1111
+ const result = this.search.handleInput(data, this.selected);
1112
+ if (result.selected != null) this.selected = result.selected;
1113
+ this.tui.requestRender();
1012
1114
  }
1013
1115
 
1014
1116
  private clearSearch(): void {
1015
- this.searchMode = false;
1016
- this.searchQuery = "";
1017
- this.draftSearchQuery = "";
1018
- this.searchMessage = "";
1117
+ this.search.clear();
1019
1118
  this.tui.requestRender();
1020
1119
  }
1021
1120
 
1022
1121
  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]!);
1122
+ const result = this.search.jump(direction, this.selected);
1123
+ if (result.selected != null) this.selected = result.selected;
1040
1124
  this.tui.requestRender();
1041
1125
  }
1042
1126
 
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
1127
  private getSplitDiffRows(): SplitDiffRow[] {
1055
1128
  if (this.splitRows) return this.splitRows;
1056
1129
 
@@ -1100,11 +1173,11 @@ export class ReviewComponent {
1100
1173
  }
1101
1174
 
1102
1175
  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;
1176
+ const raw =
1177
+ line.kind === "add" || line.kind === "remove" || line.kind === "context"
1178
+ ? line.text.slice(1)
1179
+ : line.text;
1180
+ return expandTabs(raw);
1108
1181
  }
1109
1182
 
1110
1183
  private applyDiffBackground(
@@ -1186,3 +1259,19 @@ export class ReviewComponent {
1186
1259
  return getCurrentHunkScope(this.lines, this.selected);
1187
1260
  }
1188
1261
  }
1262
+
1263
+ function expandTabs(text: string, tabSize = 4): string {
1264
+ let result = "";
1265
+ let col = 0;
1266
+ for (const char of text) {
1267
+ if (char === "\t") {
1268
+ const spaces = tabSize - (col % tabSize);
1269
+ result += " ".repeat(spaces);
1270
+ col += spaces;
1271
+ } else {
1272
+ result += char;
1273
+ col++;
1274
+ }
1275
+ }
1276
+ return result;
1277
+ }
@@ -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