newpr 0.2.0 → 0.4.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 (40) hide show
  1. package/README.md +135 -103
  2. package/package.json +1 -1
  3. package/src/analyzer/pipeline.ts +12 -11
  4. package/src/analyzer/progress.ts +2 -0
  5. package/src/cli/args.ts +1 -1
  6. package/src/cli/index.ts +8 -2
  7. package/src/cli/preflight.ts +126 -0
  8. package/src/github/fetch-pr.ts +11 -1
  9. package/src/history/store.ts +1 -0
  10. package/src/history/types.ts +1 -0
  11. package/src/llm/prompts.ts +110 -19
  12. package/src/llm/response-parser.ts +13 -1
  13. package/src/types/config.ts +1 -1
  14. package/src/types/github.ts +3 -0
  15. package/src/types/output.ts +6 -0
  16. package/src/version.ts +23 -0
  17. package/src/web/client/App.tsx +51 -3
  18. package/src/web/client/components/AppShell.tsx +180 -39
  19. package/src/web/client/components/ChatSection.tsx +4 -172
  20. package/src/web/client/components/DetailPane.tsx +58 -5
  21. package/src/web/client/components/DiffViewer.tsx +47 -1
  22. package/src/web/client/components/InputScreen.tsx +72 -2
  23. package/src/web/client/components/LoadingTimeline.tsx +19 -6
  24. package/src/web/client/components/Markdown.tsx +107 -4
  25. package/src/web/client/components/ResultsScreen.tsx +44 -3
  26. package/src/web/client/components/ReviewModal.tsx +187 -0
  27. package/src/web/client/components/SettingsPanel.tsx +63 -87
  28. package/src/web/client/hooks/useBackgroundAnalyses.ts +147 -0
  29. package/src/web/client/hooks/useChatStore.ts +244 -0
  30. package/src/web/client/hooks/useFeatures.ts +2 -1
  31. package/src/web/client/panels/GroupsPanel.tsx +15 -2
  32. package/src/web/client/panels/StoryPanel.tsx +1 -1
  33. package/src/web/index.html +1 -0
  34. package/src/web/server/routes.ts +195 -16
  35. package/src/web/server/session-manager.ts +34 -0
  36. package/src/web/server.ts +37 -4
  37. package/src/web/styles/built.css +1 -1
  38. package/src/workspace/agent.ts +22 -6
  39. package/src/workspace/explore.ts +74 -16
  40. package/src/workspace/types.ts +1 -0
@@ -2,10 +2,11 @@ import { useState, useEffect } from "react";
2
2
 
3
3
  interface Features {
4
4
  cartoon: boolean;
5
+ version: string;
5
6
  }
6
7
 
7
8
  export function useFeatures(): Features {
8
- const [features, setFeatures] = useState<Features>({ cartoon: false });
9
+ const [features, setFeatures] = useState<Features>({ cartoon: false, version: "" });
9
10
 
10
11
  useEffect(() => {
11
12
  fetch("/api/features")
@@ -47,8 +47,21 @@ export function GroupsPanel({ groups }: { groups: FileGroup[] }) {
47
47
  <span className="text-[10px] text-muted-foreground/30 shrink-0 tabular-nums">{group.files.length}</span>
48
48
  </button>
49
49
  {isOpen && (
50
- <div className="pl-[34px] pr-2 pb-3 pt-1">
51
- <p className="text-[11px] text-muted-foreground/60 leading-relaxed mb-2.5">{group.description}</p>
50
+ <div className="pl-[34px] pr-2 pb-3 pt-1 space-y-2.5">
51
+ <p className="text-[11px] text-muted-foreground/60 leading-relaxed">{group.description}</p>
52
+ {group.key_changes && group.key_changes.length > 0 && (
53
+ <ul className="space-y-1">
54
+ {group.key_changes.map((change, ci) => (
55
+ <li key={ci} className="flex gap-1.5 text-[11px] text-muted-foreground/50 leading-relaxed">
56
+ <span className="text-muted-foreground/25 shrink-0">·</span>
57
+ <span>{change}</span>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ )}
62
+ {group.risk && (
63
+ <p className="text-[10px] text-muted-foreground/40 leading-relaxed">{group.risk}</p>
64
+ )}
52
65
  <div className="space-y-0.5">
53
66
  {group.files.map((f) => (
54
67
  <div
@@ -19,7 +19,7 @@ export function StoryPanel({
19
19
  }: {
20
20
  data: NewprOutput;
21
21
  activeId: string | null;
22
- onAnchorClick: (kind: "group" | "file", id: string) => void;
22
+ onAnchorClick: (kind: "group" | "file" | "line", id: string) => void;
23
23
  }) {
24
24
  const { summary, groups, narrative } = data;
25
25
 
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>newpr</title>
7
7
  <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.28/dist/katex.min.css" crossorigin />
8
9
  <script>document.head.appendChild(Object.assign(document.createElement("link"),{rel:"stylesheet",href:"/styles.css"}))</script>
9
10
  </head>
10
11
  <body>
@@ -8,9 +8,10 @@ import { fetchPrBody, fetchPrComments } from "../../github/fetch-pr.ts";
8
8
  import { parseDiff } from "../../diff/parser.ts";
9
9
  import { parsePrInput } from "../../github/parse-pr.ts";
10
10
  import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
11
- import { startAnalysis, getSession, cancelAnalysis, subscribe } from "./session-manager.ts";
11
+ import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
12
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
13
  import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
14
+ import { detectAgents, runAgent } from "../../workspace/agent.ts";
14
15
  import { randomBytes } from "node:crypto";
15
16
 
16
17
  function json(data: unknown, status = 200): Response {
@@ -20,8 +21,11 @@ function json(data: unknown, status = 200): Response {
20
21
  });
21
22
  }
22
23
 
24
+ import type { PreflightResult } from "../../cli/preflight.ts";
25
+
23
26
  interface RouteOptions {
24
27
  cartoon?: boolean;
28
+ preflight?: PreflightResult;
25
29
  }
26
30
 
27
31
  export function createRoutes(token: string, config: NewprConfig, options: RouteOptions = {}) {
@@ -100,13 +104,19 @@ ${data.narrative}
100
104
  ${data.meta.pr_body ? `## PR Description\n${data.meta.pr_body}` : ""}
101
105
 
102
106
  ## Anchor Syntax (CRITICAL)
103
- You MUST use these anchors whenever you mention a file or group. They become clickable links in the UI.
104
- - File: [[file:exact/path/here.ts]] — renders as a clickable chip that opens the file diff in the sidebar.
105
- - Group: [[group:Exact Group Name]] — renders as a clickable chip that opens the group detail in the sidebar.
106
- - Use the EXACT paths and names from the lists above. Partial or invented names will not work.
107
- - ALWAYS prefer anchors over plain text. Instead of "the auth module", write "the [[group:Auth Flow]] group". Instead of "in session.ts", write "in [[file:src/auth/session.ts]]".
108
- - When listing multiple files, anchor each one: [[file:a.ts]], [[file:b.ts]], [[file:c.ts]].
109
- - Even in short answers, use anchors. They are the primary way users navigate from your response to the code.
107
+ You MUST use these anchors. They become clickable links in the UI.
108
+
109
+ 1. Group: [[group:Exact Group Name]] — renders as a clickable chip.
110
+ 2. File: [[file:exact/path/here.ts]] renders as a clickable chip.
111
+ 3. Line reference: [[line:exact/path/here.ts#L42-L50]](descriptive text) the "descriptive text" becomes an underlined link that opens the diff scrolled to that line. The line info is NOT shown — only the text is visible.
112
+
113
+ RULES:
114
+ - Use EXACT paths and names from the lists above.
115
+ - For line references, ALWAYS use [[line:path#L-L]](text). NEVER bare [[line:...]] without (text).
116
+ - The (text) should describe what the code does, NOT show file names or line numbers.
117
+ - Do NOT place [[file:...]] and [[line:...]] adjacent for the same file.
118
+ - Use the get_file_diff tool to find exact line numbers before referencing them.
119
+ - Aim for most statements about code to include at least one line reference.
110
120
 
111
121
  ## Math / LaTeX
112
122
  When expressing mathematical formulas, algorithms, or complexity analysis, use LaTeX syntax:
@@ -172,6 +182,42 @@ $$
172
182
  parameters: { type: "object", properties: {} },
173
183
  },
174
184
  },
185
+ {
186
+ type: "function",
187
+ function: {
188
+ name: "run_react_doctor",
189
+ description: "Run react-doctor on the PR's codebase to get a React code quality score (0-100) and diagnostics for security, performance, correctness, and architecture issues. Only useful for React/JSX/TSX projects.",
190
+ parameters: { type: "object", properties: {} },
191
+ },
192
+ },
193
+ {
194
+ type: "function",
195
+ function: {
196
+ name: "web_search",
197
+ description: "Search the web for documentation, library references, best practices, or any technical question. Returns top search results with snippets.",
198
+ parameters: {
199
+ type: "object",
200
+ properties: {
201
+ query: { type: "string", description: "Search query (e.g. 'React useEffect cleanup pattern', 'zod discriminated union')" },
202
+ },
203
+ required: ["query"],
204
+ },
205
+ },
206
+ },
207
+ {
208
+ type: "function",
209
+ function: {
210
+ name: "web_fetch",
211
+ description: "Fetch the text content of a web page. Use this to read documentation pages, blog posts, or API references found via web_search.",
212
+ parameters: {
213
+ type: "object",
214
+ properties: {
215
+ url: { type: "string", description: "URL to fetch (must start with https://)" },
216
+ },
217
+ required: ["url"],
218
+ },
219
+ },
220
+ },
175
221
  ];
176
222
  }
177
223
 
@@ -384,13 +430,11 @@ $$
384
430
  if (!allowed) return json({ error: "URL not allowed" }, 403);
385
431
 
386
432
  try {
387
- const res = await fetch(target, {
388
- headers: {
389
- "User-Agent": "newpr-cli",
390
- Authorization: `token ${token}`,
391
- },
392
- redirect: "follow",
393
- });
433
+ const headers: Record<string, string> = { "User-Agent": "newpr-cli" };
434
+ if (token && target.startsWith("https://github.com/")) {
435
+ headers.Authorization = `token ${token}`;
436
+ }
437
+ const res = await fetch(target, { headers, redirect: "follow" });
394
438
  if (!res.ok) return new Response(null, { status: res.status });
395
439
 
396
440
  const contentType = res.headers.get("content-type") ?? "application/octet-stream";
@@ -490,7 +534,47 @@ $$
490
534
  },
491
535
 
492
536
  "GET /api/features": () => {
493
- return json({ cartoon: !!options.cartoon });
537
+ const { getVersion } = require("../../version.ts");
538
+ return json({ cartoon: !!options.cartoon, version: getVersion() });
539
+ },
540
+
541
+ "POST /api/review": async (req: Request) => {
542
+ const body = await req.json() as { pr_url: string; event: string; body?: string };
543
+ if (!body.pr_url || !body.event) return json({ error: "Missing pr_url or event" }, 400);
544
+
545
+ const validEvents = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
546
+ if (!validEvents.includes(body.event)) return json({ error: `Invalid event: ${body.event}` }, 400);
547
+
548
+ try {
549
+ const pr = parsePrInput(body.pr_url);
550
+ const res = await fetch(
551
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews`,
552
+ {
553
+ method: "POST",
554
+ headers: ghHeaders,
555
+ body: JSON.stringify({
556
+ body: body.body ?? "",
557
+ event: body.event,
558
+ }),
559
+ },
560
+ );
561
+ if (!res.ok) {
562
+ const errBody = await res.json().catch(() => ({})) as { message?: string };
563
+ return json({ error: errBody.message ?? `GitHub API error: ${res.status}` }, res.status);
564
+ }
565
+ const data = await res.json() as { id: number; state: string; html_url: string };
566
+ return json({ ok: true, id: data.id, state: data.state, html_url: data.html_url });
567
+ } catch (err) {
568
+ return json({ error: err instanceof Error ? err.message : String(err) }, 500);
569
+ }
570
+ },
571
+
572
+ "GET /api/preflight": () => {
573
+ return json(options.preflight ?? null);
574
+ },
575
+
576
+ "GET /api/active-analyses": () => {
577
+ return json(listActiveSessions());
494
578
  },
495
579
 
496
580
  "GET /api/sessions/:id/comments": async (req: Request) => {
@@ -804,6 +888,101 @@ $$
804
888
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
805
889
  }
806
890
  }
891
+ case "run_react_doctor": {
892
+ const agents = await detectAgents();
893
+ if (agents.length > 0) {
894
+ try {
895
+ const result = await runAgent(
896
+ agents[0]!,
897
+ process.cwd(),
898
+ "Run react-doctor on this project:\n\nnpx -y react-doctor@latest . --verbose\n\nReturn the FULL output including the score and all diagnostics.",
899
+ { timeout: 60_000 },
900
+ );
901
+ if (result.answer.trim()) return result.answer;
902
+ } catch {}
903
+ }
904
+ try {
905
+ const proc = Bun.spawn(["npx", "-y", "react-doctor@latest", ".", "--verbose"], {
906
+ cwd: process.cwd(),
907
+ stdout: "pipe",
908
+ stderr: "pipe",
909
+ });
910
+ const output = await new Response(proc.stdout).text();
911
+ const stderr = await new Response(proc.stderr).text();
912
+ return output.trim() || stderr.trim() || "react-doctor produced no output";
913
+ } catch (err) {
914
+ return `Error running react-doctor: ${err instanceof Error ? err.message : String(err)}`;
915
+ }
916
+ }
917
+ case "web_search": {
918
+ const query = args.query as string;
919
+ if (!query) return "Error: query argument required";
920
+ const agents = await detectAgents();
921
+ if (agents.length > 0) {
922
+ try {
923
+ const result = await runAgent(agents[0]!, process.cwd(), `Search the web for: "${query}"\n\nReturn the top results with titles, URLs, and brief descriptions. Be concise.`, { timeout: 30_000 });
924
+ if (result.answer.trim()) return result.answer;
925
+ } catch {}
926
+ }
927
+ try {
928
+ const encoded = encodeURIComponent(query);
929
+ const res = await fetch(`https://html.duckduckgo.com/html/?q=${encoded}`, {
930
+ headers: { "User-Agent": "newpr-cli/0.2.0" },
931
+ });
932
+ if (!res.ok) return `Search failed: HTTP ${res.status}`;
933
+ const html = await res.text();
934
+ const results: string[] = [];
935
+ const resultRe = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
936
+ let m;
937
+ while ((m = resultRe.exec(html)) !== null && results.length < 8) {
938
+ const href = m[1]?.replace(/&amp;/g, "&") ?? "";
939
+ const title = (m[2] ?? "").replace(/<[^>]+>/g, "").trim();
940
+ const snippet = (m[3] ?? "").replace(/<[^>]+>/g, "").trim();
941
+ if (title && href) results.push(`${title}\n${href}\n${snippet}`);
942
+ }
943
+ if (results.length === 0) return `No results found for "${query}"`;
944
+ return results.join("\n\n---\n\n");
945
+ } catch (err) {
946
+ return `Search error: ${err instanceof Error ? err.message : String(err)}`;
947
+ }
948
+ }
949
+ case "web_fetch": {
950
+ const url = args.url as string;
951
+ if (!url?.startsWith("https://")) return "Error: url must start with https://";
952
+ const agents = await detectAgents();
953
+ if (agents.length > 0) {
954
+ try {
955
+ const result = await runAgent(agents[0]!, process.cwd(), `Fetch and summarize the content of this URL: ${url}\n\nReturn the key information from the page. Be thorough but concise.`, { timeout: 30_000 });
956
+ if (result.answer.trim()) return result.answer;
957
+ } catch {}
958
+ }
959
+ try {
960
+ const controller = new AbortController();
961
+ const t = setTimeout(() => controller.abort(), 15000);
962
+ const res = await fetch(url, {
963
+ signal: controller.signal,
964
+ headers: { "User-Agent": "newpr-cli/0.2.0", Accept: "text/html,text/plain,application/json" },
965
+ redirect: "follow",
966
+ });
967
+ clearTimeout(t);
968
+ if (!res.ok) return `Fetch failed: HTTP ${res.status}`;
969
+ const contentType = res.headers.get("content-type") ?? "";
970
+ const text = await res.text();
971
+ if (contentType.includes("json")) return text.slice(0, 15000);
972
+ const stripped = text
973
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
974
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
975
+ .replace(/<nav[\s\S]*?<\/nav>/gi, "")
976
+ .replace(/<footer[\s\S]*?<\/footer>/gi, "")
977
+ .replace(/<header[\s\S]*?<\/header>/gi, "")
978
+ .replace(/<[^>]+>/g, " ")
979
+ .replace(/\s+/g, " ")
980
+ .trim();
981
+ return stripped.slice(0, 15000) + (stripped.length > 15000 ? "\n\n... (truncated)" : "");
982
+ } catch (err) {
983
+ return `Fetch error: ${err instanceof Error ? err.message : String(err)}`;
984
+ }
985
+ }
807
986
  default:
808
987
  return `Unknown tool: ${name}`;
809
988
  }
@@ -9,6 +9,7 @@ type SessionStatus = "running" | "done" | "error" | "canceled";
9
9
 
10
10
  interface AnalysisSession {
11
11
  id: string;
12
+ prInput: string;
12
13
  status: SessionStatus;
13
14
  events: ProgressEvent[];
14
15
  result?: NewprOutput;
@@ -16,6 +17,8 @@ interface AnalysisSession {
16
17
  error?: string;
17
18
  startedAt: number;
18
19
  finishedAt?: number;
20
+ prTitle?: string;
21
+ prNumber?: number;
19
22
  abortController: AbortController;
20
23
  subscribers: Set<(event: ProgressEvent | { type: "done" | "error"; data?: string }) => void>;
21
24
  }
@@ -53,6 +56,7 @@ export function startAnalysis(
53
56
 
54
57
  const session: AnalysisSession = {
55
58
  id,
59
+ prInput,
56
60
  status: "running",
57
61
  events: [],
58
62
  startedAt: Date.now(),
@@ -84,6 +88,8 @@ async function runPipeline(
84
88
  onProgress: (event: ProgressEvent) => {
85
89
  const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
86
90
  session.events.push(stamped);
91
+ if (event.pr_title) session.prTitle = event.pr_title;
92
+ if (event.pr_number) session.prNumber = event.pr_number;
87
93
  for (const sub of session.subscribers) {
88
94
  sub(stamped);
89
95
  }
@@ -129,6 +135,34 @@ export function cancelAnalysis(id: string): boolean {
129
135
  return true;
130
136
  }
131
137
 
138
+ export function listActiveSessions(): Array<{
139
+ id: string;
140
+ prInput: string;
141
+ status: SessionStatus;
142
+ startedAt: number;
143
+ prTitle?: string;
144
+ prNumber?: number;
145
+ lastStage?: string;
146
+ lastMessage?: string;
147
+ }> {
148
+ const result: ReturnType<typeof listActiveSessions> = [];
149
+ for (const s of sessions.values()) {
150
+ if (s.status !== "running") continue;
151
+ const lastEvent = s.events[s.events.length - 1];
152
+ result.push({
153
+ id: s.id,
154
+ prInput: s.prInput,
155
+ status: s.status,
156
+ startedAt: s.startedAt,
157
+ prTitle: s.prTitle,
158
+ prNumber: s.prNumber,
159
+ lastStage: lastEvent?.stage,
160
+ lastMessage: lastEvent?.message,
161
+ });
162
+ }
163
+ return result;
164
+ }
165
+
132
166
  export function subscribe(
133
167
  id: string,
134
168
  callback: (event: ProgressEvent | { type: "done" | "error"; data?: string }) => void,
package/src/web/server.ts CHANGED
@@ -3,11 +3,15 @@ import type { NewprConfig } from "../types/config.ts";
3
3
  import { createRoutes } from "./server/routes.ts";
4
4
  import index from "./index.html";
5
5
 
6
+ import type { PreflightResult } from "../cli/preflight.ts";
7
+ import { getVersion } from "../version.ts";
8
+
6
9
  interface WebServerOptions {
7
10
  port: number;
8
11
  token: string;
9
12
  config: NewprConfig;
10
13
  cartoon?: boolean;
14
+ preflight?: PreflightResult;
11
15
  }
12
16
 
13
17
  function getCssPaths() {
@@ -32,8 +36,8 @@ async function buildCss(bin: string, input: string, output: string): Promise<voi
32
36
  }
33
37
 
34
38
  export async function startWebServer(options: WebServerOptions): Promise<void> {
35
- const { port, token, config, cartoon } = options;
36
- const routes = createRoutes(token, config, { cartoon });
39
+ const { port, token, config, cartoon, preflight } = options;
40
+ const routes = createRoutes(token, config, { cartoon, preflight });
37
41
  const css = getCssPaths();
38
42
 
39
43
  await buildCss(css.bin, css.input, css.output);
@@ -119,9 +123,18 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
119
123
  if (path === "/api/features" && req.method === "GET") {
120
124
  return routes["GET /api/features"]();
121
125
  }
126
+ if (path === "/api/preflight" && req.method === "GET") {
127
+ return routes["GET /api/preflight"]();
128
+ }
129
+ if (path === "/api/active-analyses" && req.method === "GET") {
130
+ return routes["GET /api/active-analyses"]();
131
+ }
122
132
  if (path === "/api/cartoon" && req.method === "POST") {
123
133
  return routes["POST /api/cartoon"](req);
124
134
  }
135
+ if (path === "/api/review" && req.method === "POST") {
136
+ return routes["POST /api/review"](req);
137
+ }
125
138
 
126
139
  return new Response("Not Found", { status: 404 });
127
140
  },
@@ -131,6 +144,26 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
131
144
  },
132
145
  });
133
146
 
134
- console.log(`\n newpr web UI`);
135
- console.log(` Local: http://localhost:${server.port}\n`);
147
+ const url = `http://localhost:${server.port}`;
148
+
149
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
150
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
151
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
152
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
153
+
154
+ console.log("");
155
+ console.log(` ${bold("newpr")} ${dim(`v${getVersion()}`)}`);
156
+ console.log("");
157
+ console.log(` ${dim("→")} Local ${cyan(url)}`);
158
+ console.log(` ${dim("→")} Model ${dim(config.model)}`);
159
+ if (cartoon) console.log(` ${dim("→")} Comic ${green("enabled")}`);
160
+ console.log("");
161
+ console.log(` ${dim("press")} ${bold("ctrl+c")} ${dim("to stop")}`);
162
+ console.log("");
163
+
164
+ try {
165
+ const { platform } = process;
166
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
167
+ Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
168
+ } catch {}
136
169
  }