pi-diff-review 0.1.6 → 0.1.7
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 +3 -4
- package/package.json +2 -1
- package/src/explain.ts +106 -0
- package/src/index.ts +2 -0
- package/src/review-component.ts +242 -1
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# pi-diff-review
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Embedded code reviews directly directly within [pi](https://pi.dev/).
|
|
6
6
|
|
|
7
|
-
<img width="
|
|
7
|
+
<img width="1986" height="1556" alt="pi-diff-review-screenshot(1)" src="https://github.com/user-attachments/assets/3fd00163-5d19-489b-94ed-3d4816c6cad3" />
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -14,8 +14,6 @@ Install from npm:
|
|
|
14
14
|
pi install npm:pi-diff-review
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
Package: https://www.npmjs.com/package/pi-diff-review
|
|
18
|
-
|
|
19
17
|
Or install directly from GitHub:
|
|
20
18
|
|
|
21
19
|
```bash
|
|
@@ -30,6 +28,7 @@ pi install https://github.com/cmpadden/pi-diff-review
|
|
|
30
28
|
- `g/G` to jump to the top or bottom of the diff
|
|
31
29
|
- `ctrl-u` / `ctrl-d` to move up/down by half a page
|
|
32
30
|
- `t` toggles the diff between unified and side-by-side split rendering
|
|
31
|
+
- `?` toggles an AI-generated explanation for the current hunk
|
|
33
32
|
- `J/K` to extend a highlighted selection into a comment range
|
|
34
33
|
- `esc` clears the active selection, or exits review when no selection is active
|
|
35
34
|
- `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.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Local diff review TUI extension for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
52
53
|
"@pierre/diffs": "^1.1.21"
|
|
53
54
|
}
|
|
54
55
|
}
|
package/src/explain.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { streamSimple } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
export type ExplanationScope = {
|
|
6
|
+
key: string;
|
|
7
|
+
kind: "hunk";
|
|
8
|
+
title: string;
|
|
9
|
+
filePath?: string;
|
|
10
|
+
diffText: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ExplanationState =
|
|
14
|
+
| { status: "loading"; text: string }
|
|
15
|
+
| { status: "ready"; text: string }
|
|
16
|
+
| { status: "error"; message: string };
|
|
17
|
+
|
|
18
|
+
export type DiffExplainer = {
|
|
19
|
+
explain(
|
|
20
|
+
scope: ExplanationScope,
|
|
21
|
+
options?: {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onDelta?: (delta: string) => void;
|
|
24
|
+
},
|
|
25
|
+
): Promise<string>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function buildExplanationPrompt(scope: ExplanationScope): string {
|
|
29
|
+
return `Explain this git diff hunk for a code reviewer.
|
|
30
|
+
|
|
31
|
+
Focus on:
|
|
32
|
+
- what changed
|
|
33
|
+
- why it matters
|
|
34
|
+
- behavioral, API, or test implications
|
|
35
|
+
- notable risks or edge cases
|
|
36
|
+
|
|
37
|
+
Keep it concise and practical. Do not suggest code changes unless they are directly relevant to understanding the diff.
|
|
38
|
+
|
|
39
|
+
\`\`\`diff
|
|
40
|
+
${scope.diffText}
|
|
41
|
+
\`\`\``;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class PiModelDiffExplainer implements DiffExplainer {
|
|
45
|
+
constructor(private ctx: ExtensionCommandContext) {}
|
|
46
|
+
|
|
47
|
+
async explain(
|
|
48
|
+
scope: ExplanationScope,
|
|
49
|
+
options: {
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
onDelta?: (delta: string) => void;
|
|
52
|
+
} = {},
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
const model = this.ctx.model;
|
|
55
|
+
if (!model) {
|
|
56
|
+
throw new Error("No model is selected.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const auth = await this.ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
60
|
+
if (!auth.ok) {
|
|
61
|
+
throw new Error(auth.error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const context: Context = {
|
|
65
|
+
systemPrompt:
|
|
66
|
+
"You explain code diffs clearly and concisely for code review. Focus on intent, behavior, and risk. Avoid restating every changed line.",
|
|
67
|
+
messages: [
|
|
68
|
+
{
|
|
69
|
+
role: "user",
|
|
70
|
+
content: buildExplanationPrompt(scope),
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let streamedText = "";
|
|
77
|
+
const stream = streamSimple(model, context, {
|
|
78
|
+
apiKey: auth.apiKey,
|
|
79
|
+
headers: auth.headers,
|
|
80
|
+
signal: options.signal,
|
|
81
|
+
maxTokens: 800,
|
|
82
|
+
reasoning: "minimal",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for await (const event of stream) {
|
|
86
|
+
if (event.type === "text_delta") {
|
|
87
|
+
streamedText += event.delta;
|
|
88
|
+
options.onDelta?.(event.delta);
|
|
89
|
+
} else if (event.type === "done") {
|
|
90
|
+
return extractText(event.message) || streamedText.trim();
|
|
91
|
+
} else if (event.type === "error") {
|
|
92
|
+
throw new Error(event.error.errorMessage ?? "Explanation failed.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return streamedText.trim();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractText(message: AssistantMessage): string {
|
|
101
|
+
return message.content
|
|
102
|
+
.filter((content) => content.type === "text")
|
|
103
|
+
.map((content) => content.text)
|
|
104
|
+
.join("")
|
|
105
|
+
.trim();
|
|
106
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { getDiff, parseDiffSource } from "./diff-source.ts";
|
|
6
6
|
import { parseDiff } from "./diff-parser.ts";
|
|
7
|
+
import { PiModelDiffExplainer } from "./explain.ts";
|
|
7
8
|
import { buildReviewPrompt } from "./prompt.ts";
|
|
8
9
|
import { ReviewComponent } from "./review-component.ts";
|
|
9
10
|
import type { DiffSource, ReviewComment, ReviewResult } from "./types.ts";
|
|
@@ -39,6 +40,7 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
39
40
|
reviewLines,
|
|
40
41
|
comments,
|
|
41
42
|
done,
|
|
43
|
+
new PiModelDiffExplainer(ctx),
|
|
42
44
|
);
|
|
43
45
|
},
|
|
44
46
|
);
|
package/src/review-component.ts
CHANGED
|
@@ -5,6 +5,11 @@ import {
|
|
|
5
5
|
visibleWidth,
|
|
6
6
|
wrapTextWithAnsi,
|
|
7
7
|
} from "@earendil-works/pi-tui";
|
|
8
|
+
import type {
|
|
9
|
+
DiffExplainer,
|
|
10
|
+
ExplanationScope,
|
|
11
|
+
ExplanationState,
|
|
12
|
+
} from "./explain.ts";
|
|
8
13
|
import { formatLocation } from "./prompt.ts";
|
|
9
14
|
import type {
|
|
10
15
|
DiffRenderMode,
|
|
@@ -14,6 +19,7 @@ import type {
|
|
|
14
19
|
ReviewResult,
|
|
15
20
|
ReviewTheme,
|
|
16
21
|
ReviewTui,
|
|
22
|
+
RightPaneMode,
|
|
17
23
|
SelectionBounds,
|
|
18
24
|
SplitDiffCell,
|
|
19
25
|
SplitDiffRow,
|
|
@@ -37,6 +43,12 @@ export class ReviewComponent {
|
|
|
37
43
|
private selectionAnchor?: number;
|
|
38
44
|
private layout: ReviewLayout = "side-by-side";
|
|
39
45
|
private diffRenderMode: DiffRenderMode = "unified";
|
|
46
|
+
private rightPaneMode: RightPaneMode = "comments";
|
|
47
|
+
private explanations = new Map<string, ExplanationState>();
|
|
48
|
+
private explanationAbort?: AbortController;
|
|
49
|
+
private explanationRequestId = 0;
|
|
50
|
+
private loadingFrame = 0;
|
|
51
|
+
private loadingTimer?: ReturnType<typeof setInterval>;
|
|
40
52
|
private editor: Editor;
|
|
41
53
|
private splitRows?: SplitDiffRow[];
|
|
42
54
|
private splitRowByLineIndex?: number[];
|
|
@@ -52,6 +64,7 @@ export class ReviewComponent {
|
|
|
52
64
|
private lines: ReviewLine[],
|
|
53
65
|
private comments: Map<string, ReviewComment>,
|
|
54
66
|
private done: (result: ReviewResult) => void,
|
|
67
|
+
private explainer?: DiffExplainer,
|
|
55
68
|
) {
|
|
56
69
|
const firstCommentable = this.lines.findIndex((line) => line.commentable);
|
|
57
70
|
this.selected = firstCommentable >= 0 ? firstCommentable : 0;
|
|
@@ -118,6 +131,10 @@ export class ReviewComponent {
|
|
|
118
131
|
this.toggleDiffRenderMode();
|
|
119
132
|
return;
|
|
120
133
|
}
|
|
134
|
+
if (data === "?") {
|
|
135
|
+
this.toggleExplanationPane();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
121
138
|
if (matchesKey(data, "ctrl+d")) {
|
|
122
139
|
this.move(this.getPageMoveAmount());
|
|
123
140
|
return;
|
|
@@ -188,7 +205,7 @@ export class ReviewComponent {
|
|
|
188
205
|
? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
|
|
189
206
|
: this.hasSelection()
|
|
190
207
|
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • Enter submit`
|
|
191
|
-
: `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • j/k move • g/G top/bottom • ctrl-u/d page • t unified/split • J/K extend • c comment • x delete • n/p hunk • Enter submit • q quit`,
|
|
208
|
+
: `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • j/k move • g/G top/bottom • ctrl-u/d page • t unified/split • ? explain • J/K extend • c comment • x delete • n/p hunk • Enter submit • q quit`,
|
|
192
209
|
),
|
|
193
210
|
width,
|
|
194
211
|
),
|
|
@@ -352,6 +369,11 @@ export class ReviewComponent {
|
|
|
352
369
|
|
|
353
370
|
invalidate(): void {}
|
|
354
371
|
|
|
372
|
+
dispose(): void {
|
|
373
|
+
this.explanationAbort?.abort();
|
|
374
|
+
this.stopLoadingTimer();
|
|
375
|
+
}
|
|
376
|
+
|
|
355
377
|
private move(delta: number): void {
|
|
356
378
|
const next = Math.max(
|
|
357
379
|
0,
|
|
@@ -386,6 +408,15 @@ export class ReviewComponent {
|
|
|
386
408
|
this.tui.requestRender(true);
|
|
387
409
|
}
|
|
388
410
|
|
|
411
|
+
private toggleExplanationPane(): void {
|
|
412
|
+
this.rightPaneMode =
|
|
413
|
+
this.rightPaneMode === "comments" ? "explanation" : "comments";
|
|
414
|
+
if (this.rightPaneMode === "explanation") {
|
|
415
|
+
this.ensureCurrentExplanation();
|
|
416
|
+
}
|
|
417
|
+
this.tui.requestRender(true);
|
|
418
|
+
}
|
|
419
|
+
|
|
389
420
|
private getPageMoveAmount(): number {
|
|
390
421
|
const contentHeight = this.getContentHeight();
|
|
391
422
|
const diffHeight =
|
|
@@ -746,6 +777,16 @@ export class ReviewComponent {
|
|
|
746
777
|
width: number,
|
|
747
778
|
height: number,
|
|
748
779
|
selectedLine?: ReviewLine,
|
|
780
|
+
): string[] {
|
|
781
|
+
return this.rightPaneMode === "explanation"
|
|
782
|
+
? this.renderExplanationPane(width, height, selectedLine)
|
|
783
|
+
: this.renderCommentsPane(width, height, selectedLine);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private renderCommentsPane(
|
|
787
|
+
width: number,
|
|
788
|
+
height: number,
|
|
789
|
+
selectedLine?: ReviewLine,
|
|
749
790
|
): string[] {
|
|
750
791
|
const lines: string[] = [];
|
|
751
792
|
const title = this.theme.fg("accent", this.theme.bold("Comments"));
|
|
@@ -875,4 +916,204 @@ export class ReviewComponent {
|
|
|
875
916
|
);
|
|
876
917
|
return lines.slice(0, height);
|
|
877
918
|
}
|
|
919
|
+
|
|
920
|
+
private renderExplanationPane(
|
|
921
|
+
width: number,
|
|
922
|
+
height: number,
|
|
923
|
+
selectedLine?: ReviewLine,
|
|
924
|
+
): string[] {
|
|
925
|
+
const lines: string[] = [];
|
|
926
|
+
const title = this.theme.fg("accent", this.theme.bold("Explanation"));
|
|
927
|
+
const scope = this.getCurrentHunkScope();
|
|
928
|
+
|
|
929
|
+
lines.push(truncateToWidth(title, width));
|
|
930
|
+
lines.push(
|
|
931
|
+
truncateToWidth(
|
|
932
|
+
this.theme.fg(
|
|
933
|
+
"dim",
|
|
934
|
+
scope?.title ??
|
|
935
|
+
(selectedLine ? formatLocation(selectedLine) : "No selection"),
|
|
936
|
+
),
|
|
937
|
+
width,
|
|
938
|
+
),
|
|
939
|
+
);
|
|
940
|
+
lines.push("");
|
|
941
|
+
|
|
942
|
+
if (!this.explainer) {
|
|
943
|
+
lines.push(
|
|
944
|
+
...wrapTextWithAnsi(
|
|
945
|
+
this.theme.fg("warning", "Diff explanations are unavailable."),
|
|
946
|
+
width,
|
|
947
|
+
),
|
|
948
|
+
);
|
|
949
|
+
return lines.slice(0, height);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (!scope) {
|
|
953
|
+
lines.push(
|
|
954
|
+
...wrapTextWithAnsi(
|
|
955
|
+
this.theme.fg(
|
|
956
|
+
"muted",
|
|
957
|
+
"Move to a changed hunk and press ? to generate an explanation.",
|
|
958
|
+
),
|
|
959
|
+
width,
|
|
960
|
+
),
|
|
961
|
+
);
|
|
962
|
+
return lines.slice(0, height);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const explanation = this.explanations.get(scope.key);
|
|
966
|
+
if (!explanation) {
|
|
967
|
+
lines.push(
|
|
968
|
+
...wrapTextWithAnsi(
|
|
969
|
+
this.theme.fg(
|
|
970
|
+
"muted",
|
|
971
|
+
"No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
|
|
972
|
+
),
|
|
973
|
+
width,
|
|
974
|
+
),
|
|
975
|
+
);
|
|
976
|
+
} else if (explanation.status === "loading") {
|
|
977
|
+
const spinner = this.getLoadingFrame();
|
|
978
|
+
lines.push(
|
|
979
|
+
truncateToWidth(
|
|
980
|
+
this.theme.fg("accent", `${spinner} Generating explanation...`),
|
|
981
|
+
width,
|
|
982
|
+
),
|
|
983
|
+
);
|
|
984
|
+
if (explanation.text.trim()) {
|
|
985
|
+
lines.push("");
|
|
986
|
+
lines.push(
|
|
987
|
+
...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
} else if (explanation.status === "error") {
|
|
991
|
+
lines.push(
|
|
992
|
+
...wrapTextWithAnsi(
|
|
993
|
+
this.theme.fg(
|
|
994
|
+
"warning",
|
|
995
|
+
`Unable to explain diff: ${explanation.message}`,
|
|
996
|
+
),
|
|
997
|
+
width,
|
|
998
|
+
),
|
|
999
|
+
);
|
|
1000
|
+
} else {
|
|
1001
|
+
lines.push(
|
|
1002
|
+
...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
lines.push("");
|
|
1007
|
+
lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "? comments"), width));
|
|
1008
|
+
return lines.slice(0, height);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private ensureCurrentExplanation(): void {
|
|
1012
|
+
const scope = this.getCurrentHunkScope();
|
|
1013
|
+
if (!scope || !this.explainer) return;
|
|
1014
|
+
if (this.explanations.has(scope.key)) return;
|
|
1015
|
+
|
|
1016
|
+
this.explanationAbort?.abort();
|
|
1017
|
+
const controller = new AbortController();
|
|
1018
|
+
this.explanationAbort = controller;
|
|
1019
|
+
const requestId = ++this.explanationRequestId;
|
|
1020
|
+
let text = "";
|
|
1021
|
+
|
|
1022
|
+
this.explanations.set(scope.key, { status: "loading", text });
|
|
1023
|
+
this.startLoadingTimer();
|
|
1024
|
+
|
|
1025
|
+
void this.explainer
|
|
1026
|
+
.explain(scope, {
|
|
1027
|
+
signal: controller.signal,
|
|
1028
|
+
onDelta: (delta) => {
|
|
1029
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1030
|
+
text += delta;
|
|
1031
|
+
this.explanations.set(scope.key, { status: "loading", text });
|
|
1032
|
+
this.tui.requestRender();
|
|
1033
|
+
},
|
|
1034
|
+
})
|
|
1035
|
+
.then((finalText) => {
|
|
1036
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1037
|
+
this.explanations.set(scope.key, {
|
|
1038
|
+
status: "ready",
|
|
1039
|
+
text: finalText.trim() || text.trim() || "No explanation returned.",
|
|
1040
|
+
});
|
|
1041
|
+
})
|
|
1042
|
+
.catch((error) => {
|
|
1043
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1044
|
+
if (controller.signal.aborted) return;
|
|
1045
|
+
this.explanations.set(scope.key, {
|
|
1046
|
+
status: "error",
|
|
1047
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1048
|
+
});
|
|
1049
|
+
})
|
|
1050
|
+
.finally(() => {
|
|
1051
|
+
if (requestId !== this.explanationRequestId) return;
|
|
1052
|
+
this.stopLoadingTimerIfIdle();
|
|
1053
|
+
this.tui.requestRender();
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
this.tui.requestRender();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private getCurrentHunkScope(): ExplanationScope | undefined {
|
|
1060
|
+
const selectedLine = this.lines[this.selected];
|
|
1061
|
+
if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
|
|
1062
|
+
|
|
1063
|
+
let start = this.selected;
|
|
1064
|
+
while (
|
|
1065
|
+
start > 0 &&
|
|
1066
|
+
this.lines[start - 1]?.filePath === selectedLine.filePath &&
|
|
1067
|
+
this.lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
|
|
1068
|
+
) {
|
|
1069
|
+
start--;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
let end = this.selected;
|
|
1073
|
+
while (
|
|
1074
|
+
end + 1 < this.lines.length &&
|
|
1075
|
+
this.lines[end + 1]?.filePath === selectedLine.filePath &&
|
|
1076
|
+
this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
|
|
1077
|
+
) {
|
|
1078
|
+
end++;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const diffText = this.lines
|
|
1082
|
+
.slice(start, end + 1)
|
|
1083
|
+
.map((line) => line.text)
|
|
1084
|
+
.join("\n");
|
|
1085
|
+
return {
|
|
1086
|
+
key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
|
|
1087
|
+
kind: "hunk",
|
|
1088
|
+
title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
|
|
1089
|
+
filePath: selectedLine.filePath,
|
|
1090
|
+
diffText,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private getLoadingFrame(): string {
|
|
1095
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1096
|
+
return frames[this.loadingFrame % frames.length] ?? "⠋";
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private startLoadingTimer(): void {
|
|
1100
|
+
if (this.loadingTimer) return;
|
|
1101
|
+
this.loadingTimer = setInterval(() => {
|
|
1102
|
+
this.loadingFrame++;
|
|
1103
|
+
this.tui.requestRender();
|
|
1104
|
+
}, 120);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private stopLoadingTimerIfIdle(): void {
|
|
1108
|
+
const hasLoading = [...this.explanations.values()].some(
|
|
1109
|
+
(explanation) => explanation.status === "loading",
|
|
1110
|
+
);
|
|
1111
|
+
if (!hasLoading) this.stopLoadingTimer();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private stopLoadingTimer(): void {
|
|
1115
|
+
if (!this.loadingTimer) return;
|
|
1116
|
+
clearInterval(this.loadingTimer);
|
|
1117
|
+
this.loadingTimer = undefined;
|
|
1118
|
+
}
|
|
878
1119
|
}
|