pi-mono-all 1.0.0

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerReviewCommand } from "./review.js";
3
+ import { registerReviewTuiCommand } from "./review-tui.js";
4
+
5
+ export default function reviewExtension(pi: ExtensionAPI): void {
6
+ registerReviewCommand(pi);
7
+ registerReviewTuiCommand(pi);
8
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "pi-mono-review",
3
+ "version": "1.8.1",
4
+ "description": "Pi extension for reviewing GitHub PRs and GitLab MRs",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension"
8
+ ],
9
+ "peerDependencies": {
10
+ "@mariozechner/pi-ai": "*",
11
+ "@mariozechner/pi-coding-agent": "*",
12
+ "@mariozechner/pi-tui": "*",
13
+ "@sinclair/typebox": "*"
14
+ },
15
+ "pi": {
16
+ "extensions": [
17
+ "./index.ts"
18
+ ]
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/emanuelcasco/pi-mono-extensions.git",
23
+ "directory": "extensions/review"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/emanuelcasco/pi-mono-extensions/issues"
27
+ },
28
+ "homepage": "https://github.com/emanuelcasco/pi-mono-extensions#readme"
29
+ }
@@ -0,0 +1,194 @@
1
+ import { BorderedLoader, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import {
4
+ buildReviewSession,
5
+ cloneSession,
6
+ getLatestReviewSession,
7
+ persistReviewSession,
8
+ submitReviewComments,
9
+ type ReviewAction,
10
+ type ReviewComment,
11
+ type ReviewResult,
12
+ type ReviewSession,
13
+ } from "./common.js";
14
+ import { ReviewerComponent } from "./reviewer.js";
15
+
16
+ export function registerReviewTuiCommand(pi: ExtensionAPI): void {
17
+ let latestSession: ReviewSession | null = null;
18
+
19
+ function reconstructState(ctx: ExtensionContext) {
20
+ latestSession = getLatestReviewSession(ctx);
21
+ }
22
+
23
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
24
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
25
+
26
+ pi.registerMessageRenderer("review-submit", (message, _options, theme) => {
27
+ return new Text(theme.fg("success", theme.bold("submitted ")) + message.content, 0, 0);
28
+ });
29
+
30
+ async function runReviewPane(ctx: ExtensionContext, session: ReviewSession): Promise<ReviewResult> {
31
+ const reviewSession = cloneSession(session);
32
+ let currentIndex = 0;
33
+
34
+ // eslint-disable-next-line no-constant-condition
35
+ while (true) {
36
+ const action = await ctx.ui.custom<ReviewAction>(
37
+ (tui, theme, _kb, done) => {
38
+ const reviewer = new ReviewerComponent(reviewSession.comments, currentIndex, theme, done);
39
+ return {
40
+ render: (w: number) => reviewer.render(w),
41
+ invalidate: () => reviewer.invalidate(),
42
+ handleInput: (data: string) => {
43
+ reviewer.handleInput(data);
44
+ tui.requestRender();
45
+ },
46
+ };
47
+ },
48
+ {
49
+ overlay: true,
50
+ overlayOptions: {
51
+ anchor: "right-center",
52
+ width: "58%",
53
+ minWidth: 72,
54
+ maxHeight: "92%",
55
+ margin: 1,
56
+ },
57
+ },
58
+ );
59
+
60
+ if (action.type === "cancel") {
61
+ return { session: reviewSession, approved: 0, dismissed: 0, edited: 0, cancelled: true };
62
+ }
63
+
64
+ if (action.type === "edit") {
65
+ currentIndex = action.index;
66
+ const comment = reviewSession.comments[action.index];
67
+ if (comment) {
68
+ const newBody = await ctx.ui.editor("Edit review comment:", comment.body);
69
+ if (newBody !== undefined && newBody.trim()) {
70
+ if (!comment.originalBody) comment.originalBody = comment.body;
71
+ comment.body = newBody.trim();
72
+ comment.status = "edited";
73
+ }
74
+ }
75
+ continue;
76
+ }
77
+
78
+ const selectedComments = reviewSession.comments.filter((c) => c.status === "approved" || c.status === "edited");
79
+ if (selectedComments.length === 0) {
80
+ ctx.ui.notify("No comments selected for submission", "warning");
81
+ continue;
82
+ }
83
+
84
+ const submission = await ctx.ui.custom<{ submitted: number; failed: string[] } | null>((tui, theme, _kb, done) => {
85
+ const loader = new BorderedLoader(
86
+ tui,
87
+ theme,
88
+ `Submitting ${selectedComments.length} comment(s) to ${reviewSession.target.platform}...`,
89
+ );
90
+ loader.onAbort = () => done(null);
91
+ void (async () => {
92
+ try {
93
+ done(await submitReviewComments(pi.exec.bind(pi), reviewSession.target, selectedComments, loader.signal));
94
+ } catch (error) {
95
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
96
+ done(null);
97
+ }
98
+ })();
99
+ return loader;
100
+ });
101
+
102
+ if (!submission) continue;
103
+
104
+ reviewSession.submittedAt = Date.now();
105
+ latestSession = reviewSession;
106
+ persistReviewSession(pi, reviewSession);
107
+ return {
108
+ session: reviewSession,
109
+ approved: reviewSession.comments.filter((c) => c.status === "approved").length,
110
+ dismissed: reviewSession.comments.filter((c) => c.status === "dismissed").length,
111
+ edited: reviewSession.comments.filter((c) => c.status === "edited").length,
112
+ cancelled: false,
113
+ submitted: submission.submitted,
114
+ failed: submission.failed,
115
+ };
116
+ }
117
+ }
118
+
119
+ pi.registerCommand("review-tui", {
120
+ description: "Open the latest saved review in a side pane and submit selected comments",
121
+ handler: async (args, ctx) => {
122
+ if (!ctx.hasUI) {
123
+ ctx.ui.notify("/review-tui requires interactive mode", "error");
124
+ return;
125
+ }
126
+
127
+ latestSession = getLatestReviewSession(ctx);
128
+ const url = args?.trim();
129
+
130
+ if (!latestSession && !url) {
131
+ ctx.ui.notify("No saved review found. Run /review <url> first, or use /review-tui <url>.", "info");
132
+ return;
133
+ }
134
+
135
+ if (url) {
136
+ if (!ctx.model) {
137
+ ctx.ui.notify("No model selected", "error");
138
+ return;
139
+ }
140
+ const generated = await ctx.ui.custom<ReviewSession | null>((tui, theme, _kb, done) => {
141
+ const loader = new BorderedLoader(tui, theme, `Reviewing ${url}...`);
142
+ loader.onAbort = () => done(null);
143
+ void (async () => {
144
+ try {
145
+ done(await buildReviewSession(pi.exec.bind(pi), ctx.model!, ctx.modelRegistry, url, loader.signal));
146
+ } catch (error) {
147
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
148
+ done(null);
149
+ }
150
+ })();
151
+ return loader;
152
+ });
153
+ if (!generated) return;
154
+ latestSession = generated;
155
+ persistReviewSession(pi, generated);
156
+ }
157
+
158
+ const session = latestSession!;
159
+ const cloned = cloneSession(session);
160
+ const prepared: ReviewSession = {
161
+ ...cloned,
162
+ comments: cloned.comments.map((comment: ReviewComment) => ({
163
+ ...comment,
164
+ status:
165
+ comment.status === "approved" || comment.status === "edited" || comment.status === "dismissed"
166
+ ? comment.status
167
+ : "pending",
168
+ })),
169
+ };
170
+
171
+ const result = await runReviewPane(ctx, prepared);
172
+ if (result.cancelled) {
173
+ ctx.ui.notify("Review submission cancelled", "info");
174
+ return;
175
+ }
176
+
177
+ const failureSuffix = result.failed && result.failed.length > 0 ? ` (${result.failed.length} failed)` : "";
178
+ pi.sendMessage({
179
+ customType: "review-submit",
180
+ content:
181
+ `${result.submitted || 0} comment(s) submitted to ${result.session.target.platform}: ${result.session.target.url}` +
182
+ failureSuffix,
183
+ display: true,
184
+ details: result,
185
+ });
186
+
187
+ if (result.failed && result.failed.length > 0) {
188
+ ctx.ui.notify(`Submitted ${result.submitted || 0}, failed ${result.failed.length}`, "warning");
189
+ } else {
190
+ ctx.ui.notify(`Submitted ${result.submitted || 0} comment(s)`, "info");
191
+ }
192
+ },
193
+ });
194
+ }
@@ -0,0 +1,119 @@
1
+ import { BorderedLoader, type ExtensionAPI, type Theme } from "@mariozechner/pi-coding-agent";
2
+ import { Container, Text } from "@mariozechner/pi-tui";
3
+ import {
4
+ buildReviewSession,
5
+ getCommentBody,
6
+ getCommentConfidence,
7
+ getCommentPriority,
8
+ getCommentTitle,
9
+ getPriorityInfo,
10
+ persistReviewSession,
11
+ renderSummary,
12
+ type ReviewSession,
13
+ } from "./common.js";
14
+
15
+ function renderReviewSummaryMessage(session: ReviewSession | undefined, content: string, expanded: boolean, theme: Theme) {
16
+ if (!session) return new Text(theme.fg("accent", theme.bold("review ")) + content, 0, 0);
17
+
18
+ const container = new Container();
19
+ const comments = session.comments || [];
20
+ const counts = {
21
+ P0: comments.filter((comment) => getCommentPriority(comment) === "P0").length,
22
+ P1: comments.filter((comment) => getCommentPriority(comment) === "P1").length,
23
+ P2: comments.filter((comment) => getCommentPriority(comment) === "P2").length,
24
+ P3: comments.filter((comment) => getCommentPriority(comment) === "P3").length,
25
+ };
26
+ const countText = [`${comments.length} finding${comments.length === 1 ? "" : "s"}`]
27
+ .concat((Object.entries(counts) as Array<[keyof typeof counts, number]>).filter(([, count]) => count > 0).map(([priority, count]) => `${count} ${priority}`))
28
+ .join(", ");
29
+
30
+ container.addChild(new Text(`${theme.fg("accent", theme.bold("review "))}${theme.fg("dim", session.target.url)} ${theme.fg("muted", countText)}`, 0, 0));
31
+ if (session.summary) container.addChild(new Text(` ${theme.fg("dim", session.summary)}`, 0, 0));
32
+
33
+ const displayCount = expanded ? comments.length : Math.min(3, comments.length);
34
+ for (let i = 0; i < displayCount; i++) {
35
+ const comment = comments[i]!;
36
+ const priority = getCommentPriority(comment);
37
+ const meta = getPriorityInfo(priority);
38
+ const end = comment.endLine && comment.endLine !== comment.line ? `-${comment.endLine}` : "";
39
+ container.addChild(
40
+ new Text(
41
+ ` ${theme.fg(meta.color, `${meta.symbol} [${priority}]`)} ${getCommentTitle(comment)} ${theme.fg(
42
+ "dim",
43
+ `${comment.file}:${comment.line}${end} ${(getCommentConfidence(comment) * 100).toFixed(0)}%`,
44
+ )}`,
45
+ 0,
46
+ 0,
47
+ ),
48
+ );
49
+ if (expanded) {
50
+ const body = getCommentBody(comment);
51
+ if (body) container.addChild(new Text(` ${theme.fg("dim", body.split("\n")[0] || "")}`, 0, 0));
52
+ }
53
+ }
54
+ if (comments.length > displayCount) {
55
+ container.addChild(new Text(theme.fg("dim", ` … ${comments.length - displayCount} more findings`), 0, 0));
56
+ }
57
+ if (comments.length > 0) container.addChild(new Text(theme.fg("dim", " Open /review-tui to inspect, edit, toggle, and submit."), 0, 0));
58
+ return container;
59
+ }
60
+
61
+ export function registerReviewCommand(pi: ExtensionAPI): void {
62
+ pi.registerMessageRenderer<ReviewSession>("review-summary", (message, options, theme) => {
63
+ const content = typeof message.content === "string" ? message.content : "";
64
+ return renderReviewSummaryMessage(message.details, content, options.expanded, theme);
65
+ });
66
+
67
+ pi.registerCommand("review", {
68
+ description: "Review a GitHub PR or GitLab MR URL and store comments for /review-tui",
69
+ handler: async (args, ctx) => {
70
+ if (!ctx.hasUI) {
71
+ ctx.ui.notify("/review requires interactive mode", "error");
72
+ return;
73
+ }
74
+ if (!ctx.model) {
75
+ ctx.ui.notify("No model selected", "error");
76
+ return;
77
+ }
78
+
79
+ const url = args?.trim();
80
+ if (!url) {
81
+ ctx.ui.notify("Usage: /review <github-pr-url|gitlab-mr-url>", "warning");
82
+ return;
83
+ }
84
+
85
+ const result = await ctx.ui.custom<ReviewSession | null>((tui, theme, _kb, done) => {
86
+ const loader = new BorderedLoader(tui, theme, `Reviewing ${url}...`);
87
+ loader.onAbort = () => done(null);
88
+
89
+ void (async () => {
90
+ try {
91
+ done(await buildReviewSession(pi.exec.bind(pi), ctx.model!, ctx.modelRegistry, url, loader.signal));
92
+ } catch (error) {
93
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
94
+ done(null);
95
+ }
96
+ })();
97
+
98
+ return loader;
99
+ });
100
+
101
+ if (!result) return;
102
+
103
+ persistReviewSession(pi, result);
104
+ pi.sendMessage({
105
+ customType: "review-summary",
106
+ content: renderSummary(result),
107
+ display: true,
108
+ details: result,
109
+ });
110
+
111
+ ctx.ui.notify(
112
+ result.comments.length > 0
113
+ ? `Review ready: ${result.comments.length} comment(s). Open /review-tui.`
114
+ : "Review complete: no actionable comments.",
115
+ "info",
116
+ );
117
+ },
118
+ });
119
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * ReviewerComponent — Interactive TUI for reviewing code review comments
3
+ */
4
+
5
+ import type { Theme } from "@mariozechner/pi-coding-agent";
6
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
7
+ import type { KeyId } from "@mariozechner/pi-tui";
8
+ import {
9
+ getCommentBody,
10
+ getCommentConfidence,
11
+ getCommentPriority,
12
+ getCommentTitle,
13
+ getPriorityInfo,
14
+ type CommentStatus,
15
+ type ReviewAction,
16
+ type ReviewComment,
17
+ } from "./common.js";
18
+
19
+ function priorityLabel(comment: ReviewComment): string {
20
+ const priority = getCommentPriority(comment);
21
+ return `${getPriorityInfo(priority).symbol} ${priority} ${(getCommentConfidence(comment) * 100).toFixed(0)}% confidence`;
22
+ }
23
+
24
+ function statusIcon(status: CommentStatus): string {
25
+ switch (status) {
26
+ case "approved":
27
+ return "✓";
28
+ case "dismissed":
29
+ return "✗";
30
+ case "edited":
31
+ return "✎";
32
+ case "pending":
33
+ return "○";
34
+ }
35
+ }
36
+
37
+ function statusColor(status: CommentStatus): "success" | "error" | "accent" | "warning" {
38
+ switch (status) {
39
+ case "approved":
40
+ return "success";
41
+ case "dismissed":
42
+ return "error";
43
+ case "edited":
44
+ return "accent";
45
+ case "pending":
46
+ return "warning";
47
+ }
48
+ }
49
+
50
+ function expandTabs(text: string): string {
51
+ return text.replace(/\t/g, " ");
52
+ }
53
+
54
+ interface HunkWindow {
55
+ header: string | null;
56
+ lines: string[];
57
+ hiddenBefore: number;
58
+ hiddenAfter: number;
59
+ }
60
+
61
+ function sliceHunkAroundLines(hunk: string, targetStart: number, targetEnd: number, maxLines: number): HunkWindow {
62
+ const all = hunk.split("\n");
63
+ const headerMatch = all[0]?.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
64
+ if (!headerMatch) {
65
+ return { header: null, lines: all.slice(0, maxLines), hiddenBefore: 0, hiddenAfter: Math.max(0, all.length - maxLines) };
66
+ }
67
+
68
+ const header = all[0]!;
69
+ const body = all.slice(1);
70
+ let newLine = Number(headerMatch[1]);
71
+ const lineNumbers: (number | null)[] = body.map((line) => {
72
+ if (line.startsWith("+") || line.startsWith(" ")) return newLine++;
73
+ return null;
74
+ });
75
+
76
+ let firstHit = -1;
77
+ let lastHit = -1;
78
+ for (let i = 0; i < body.length; i++) {
79
+ const n = lineNumbers[i];
80
+ if (n != null && n >= targetStart && n <= targetEnd) {
81
+ if (firstHit === -1) firstHit = i;
82
+ lastHit = i;
83
+ }
84
+ }
85
+
86
+ const budget = Math.max(1, maxLines - 1);
87
+ if (firstHit === -1) {
88
+ const slice = body.slice(0, budget);
89
+ return { header, lines: slice, hiddenBefore: 0, hiddenAfter: Math.max(0, body.length - slice.length) };
90
+ }
91
+
92
+ const span = lastHit - firstHit + 1;
93
+ let start: number;
94
+ let end: number;
95
+ if (span >= budget) {
96
+ start = firstHit;
97
+ end = firstHit + budget;
98
+ } else {
99
+ const padding = budget - span;
100
+ const before = Math.floor(padding / 2);
101
+ start = Math.max(0, firstHit - before);
102
+ end = Math.min(body.length, start + budget);
103
+ start = Math.max(0, end - budget);
104
+ }
105
+
106
+ return {
107
+ header,
108
+ lines: body.slice(start, end),
109
+ hiddenBefore: start,
110
+ hiddenAfter: Math.max(0, body.length - end),
111
+ };
112
+ }
113
+
114
+ export class ReviewerComponent {
115
+ private comments: ReviewComment[];
116
+ private currentIndex: number;
117
+ private theme: Theme;
118
+ private onDone: (action: ReviewAction) => void;
119
+ private cachedWidth?: number;
120
+ private cachedLines?: string[];
121
+
122
+ constructor(comments: ReviewComment[], startIndex: number, theme: Theme, onDone: (action: ReviewAction) => void) {
123
+ this.comments = comments;
124
+ this.currentIndex = Math.max(0, Math.min(startIndex, comments.length - 1));
125
+ this.theme = theme;
126
+ this.onDone = onDone;
127
+ }
128
+
129
+ handleInput(data: string): void {
130
+ if (this.comments.length === 0) {
131
+ if (matchesKey(data, Key.escape) || data === "q") this.onDone({ type: "cancel" });
132
+ return;
133
+ }
134
+
135
+ const comment = this.comments[this.currentIndex]!;
136
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("k"))) {
137
+ if (this.currentIndex > 0) {
138
+ this.currentIndex--;
139
+ this.invalidate();
140
+ }
141
+ return;
142
+ }
143
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("j"))) {
144
+ if (this.currentIndex < this.comments.length - 1) {
145
+ this.currentIndex++;
146
+ this.invalidate();
147
+ }
148
+ return;
149
+ }
150
+ if (data === "[") {
151
+ for (let i = this.currentIndex - 1; i >= 0; i--) {
152
+ if (this.comments[i]!.file !== comment.file) {
153
+ this.currentIndex = i;
154
+ this.invalidate();
155
+ return;
156
+ }
157
+ }
158
+ return;
159
+ }
160
+ if (data === "]") {
161
+ for (let i = this.currentIndex + 1; i < this.comments.length; i++) {
162
+ if (this.comments[i]!.file !== comment.file) {
163
+ this.currentIndex = i;
164
+ this.invalidate();
165
+ return;
166
+ }
167
+ }
168
+ return;
169
+ }
170
+ if (matchesKey(data, Key.ctrl("a"))) {
171
+ comment.status = comment.status === "approved" ? "pending" : "approved";
172
+ this.invalidate();
173
+ return;
174
+ }
175
+ if (matchesKey(data, Key.ctrl("d"))) {
176
+ comment.status = comment.status === "dismissed" ? "pending" : "dismissed";
177
+ this.invalidate();
178
+ return;
179
+ }
180
+ if (matchesKey(data, Key.ctrl("e")) || matchesKey(data, Key.enter)) {
181
+ this.onDone({ type: "edit", index: this.currentIndex });
182
+ return;
183
+ }
184
+ if (matchesKey(data, Key.ctrl("p"))) {
185
+ for (const c of this.comments) if (c.status === "pending") c.status = "approved";
186
+ this.invalidate();
187
+ return;
188
+ }
189
+ if (matchesKey(data, Key.ctrl("s"))) {
190
+ this.onDone({ type: "submit" });
191
+ return;
192
+ }
193
+ if (matchesKey(data, Key.escape) || data === "q") {
194
+ this.onDone({ type: "cancel" });
195
+ }
196
+ }
197
+
198
+ render(width: number): string[] {
199
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
200
+ const th = this.theme;
201
+ const lines: string[] = [];
202
+ const maxW = Math.max(40, Math.min(width, 120));
203
+ const innerW = Math.max(20, maxW - 2);
204
+
205
+ const row = (content = "") => {
206
+ // The TUI renderer validates terminal-visible width after the terminal expands tabs.
207
+ // Keep every custom-rendered row bounded by normalizing tabs before measuring/padding.
208
+ const normalized = expandTabs(content);
209
+ const fitted = visibleWidth(normalized) > innerW ? truncateToWidth(normalized, innerW) : normalized;
210
+ const padding = Math.max(0, innerW - visibleWidth(fitted));
211
+ lines.push(th.fg("border", "│") + fitted + " ".repeat(padding) + th.fg("border", "│"));
212
+ };
213
+
214
+ const addWrapped = (content: string, prefix = "") => {
215
+ const normalizedPrefix = expandTabs(prefix);
216
+ const available = Math.max(8, innerW - visibleWidth(normalizedPrefix));
217
+ for (const part of wrapTextWithAnsi(expandTabs(content), available)) {
218
+ row(normalizedPrefix + part);
219
+ }
220
+ };
221
+
222
+ const divider = () => row(th.fg("dim", "─".repeat(innerW)));
223
+ const top = () => lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
224
+ const bottom = () => lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
225
+
226
+ const approved = this.comments.filter((c) => c.status === "approved" || c.status === "edited").length;
227
+ const dismissed = this.comments.filter((c) => c.status === "dismissed").length;
228
+ const pending = this.comments.filter((c) => c.status === "pending").length;
229
+
230
+ top();
231
+ row(
232
+ th.fg("accent", th.bold(" Code Review ")) +
233
+ th.fg("muted", ` ${this.currentIndex + 1}/${this.comments.length} `) +
234
+ th.fg("dim", "│ ") +
235
+ [th.fg("success", `✓${approved}`), th.fg("error", `✗${dismissed}`), th.fg("warning", `○${pending}`)].join(
236
+ th.fg("dim", " │ "),
237
+ ),
238
+ );
239
+ divider();
240
+
241
+ if (this.comments.length === 0) {
242
+ row("");
243
+ addWrapped(th.fg("dim", "No comments to review"), " ");
244
+ row("");
245
+ addWrapped(th.fg("dim", "Esc/q close"), " ");
246
+ bottom();
247
+ this.cachedLines = lines;
248
+ this.cachedWidth = width;
249
+ return lines;
250
+ }
251
+
252
+ const comment = this.comments[this.currentIndex]!;
253
+ row("");
254
+ const lineInfo = comment.endLine
255
+ ? `${th.fg("warning", String(comment.line))}${th.fg("dim", "-")}${th.fg("warning", String(comment.endLine))}`
256
+ : th.fg("warning", String(comment.line));
257
+ addWrapped(`${th.fg("accent", comment.file)}${th.fg("dim", ":")}${lineInfo}`, " ");
258
+ row("");
259
+
260
+ if (comment.codeContext) {
261
+ const targetStart = Math.min(comment.line, comment.endLine ?? comment.line);
262
+ const targetEnd = Math.max(comment.line, comment.endLine ?? comment.line);
263
+ const window = sliceHunkAroundLines(comment.codeContext, targetStart, targetEnd, 12);
264
+ const linePrefix = ` ${th.fg("dim", "┃")} `;
265
+ if (window.header) addWrapped(th.fg("dim", window.header), linePrefix);
266
+ if (window.hiddenBefore > 0) {
267
+ addWrapped(th.fg("dim", `... ${window.hiddenBefore} earlier line${window.hiddenBefore !== 1 ? "s" : ""}`), linePrefix);
268
+ }
269
+ for (const cl of window.lines) {
270
+ const styled = cl.startsWith("+")
271
+ ? th.fg("toolDiffAdded", cl)
272
+ : cl.startsWith("-")
273
+ ? th.fg("toolDiffRemoved", cl)
274
+ : th.fg("toolDiffContext", cl);
275
+ addWrapped(styled, linePrefix);
276
+ }
277
+ if (window.hiddenAfter > 0) {
278
+ addWrapped(th.fg("dim", `... ${window.hiddenAfter} later line${window.hiddenAfter !== 1 ? "s" : ""}`), linePrefix);
279
+ }
280
+ row("");
281
+ }
282
+
283
+ const priority = getCommentPriority(comment);
284
+ addWrapped(th.fg(getPriorityInfo(priority).color, priorityLabel(comment)), " ");
285
+ divider();
286
+ row("");
287
+ addWrapped(th.fg("text", th.bold(getCommentTitle(comment))), " ");
288
+ row("");
289
+ const bodyLines = getCommentBody(comment).split("\n");
290
+ for (const bl of bodyLines.slice(0, 20)) addWrapped(th.fg("text", bl), " ");
291
+ if (bodyLines.length > 20) addWrapped(th.fg("dim", `... ${bodyLines.length - 20} more lines`), " ");
292
+ if (comment.status === "edited" && comment.originalBody) {
293
+ row("");
294
+ addWrapped(th.fg("dim", "original: " + comment.originalBody), " ");
295
+ }
296
+ row("");
297
+ addWrapped(th.fg(statusColor(comment.status), `${statusIcon(comment.status)} ${comment.status.charAt(0).toUpperCase() + comment.status.slice(1)}`), " ");
298
+ divider();
299
+ row("");
300
+
301
+ const maxDots = 40;
302
+ let dotsSlice = this.comments;
303
+ let dotsOffset = 0;
304
+ let prefixEllipsis = false;
305
+ let suffixEllipsis = false;
306
+ if (this.comments.length > maxDots) {
307
+ const half = Math.floor(maxDots / 2);
308
+ let start = Math.max(0, this.currentIndex - half);
309
+ let end = start + maxDots;
310
+ if (end > this.comments.length) {
311
+ end = this.comments.length;
312
+ start = Math.max(0, end - maxDots);
313
+ }
314
+ dotsSlice = this.comments.slice(start, end);
315
+ dotsOffset = start;
316
+ prefixEllipsis = start > 0;
317
+ suffixEllipsis = end < this.comments.length;
318
+ }
319
+ const dots = dotsSlice
320
+ .map((c, i) => {
321
+ const idx = dotsOffset + i;
322
+ return th.fg(idx === this.currentIndex ? "accent" : statusColor(c.status), idx === this.currentIndex ? "●" : statusIcon(c.status));
323
+ })
324
+ .join(" ");
325
+ addWrapped(`${prefixEllipsis ? th.fg("dim", "… ") : ""}${dots}${suffixEllipsis ? th.fg("dim", " …") : ""}`, " ");
326
+ row("");
327
+ addWrapped(th.fg("dim", "↑↓/^j^k navigate [/] prev/next file ^a approve ^d dismiss ^e/Enter edit"), " ");
328
+ addWrapped(th.fg("dim", "^p approve pending ^s submit Esc/q cancel"), " ");
329
+ bottom();
330
+ this.cachedLines = lines;
331
+ this.cachedWidth = width;
332
+ return lines;
333
+ }
334
+
335
+ invalidate(): void {
336
+ this.cachedWidth = undefined;
337
+ this.cachedLines = undefined;
338
+ }
339
+ }