newpr 0.5.0 → 0.5.2

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.
@@ -240,7 +240,7 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
240
240
  className={`underline decoration-1 underline-offset-[3px] cursor-pointer transition-colors ${
241
241
  isActive
242
242
  ? "decoration-blue-500 dark:decoration-blue-400 bg-blue-500/5 rounded-sm"
243
- : "decoration-foreground/15 hover:decoration-foreground/40"
243
+ : "decoration-foreground/30 hover:decoration-foreground/60"
244
244
  }`}
245
245
  >
246
246
  {children}
@@ -248,7 +248,7 @@ export function Markdown({ children, onAnchorClick, activeId }: MarkdownProps) {
248
248
  );
249
249
  }
250
250
  if (lineRef) {
251
- return <span className="underline decoration-foreground/10 decoration-1 underline-offset-[3px]">{children}</span>;
251
+ return <span className="underline decoration-foreground/25 decoration-1 underline-offset-[3px]">{children}</span>;
252
252
  }
253
253
  const { node, ...rest } = allProps as Record<string, unknown> & { node?: unknown };
254
254
  return <span {...rest as React.HTMLAttributes<HTMLSpanElement>}>{children}</span>;
@@ -49,6 +49,7 @@ export function ResultsScreen({
49
49
  sessionId,
50
50
  onTabChange,
51
51
  onReanalyze,
52
+ enabledPlugins,
52
53
  }: {
53
54
  data: NewprOutput;
54
55
  onBack: () => void;
@@ -58,6 +59,7 @@ export function ResultsScreen({
58
59
  sessionId?: string | null;
59
60
  onTabChange?: (tab: string) => void;
60
61
  onReanalyze?: (prUrl: string) => void;
62
+ enabledPlugins?: string[];
61
63
  }) {
62
64
  const { meta, summary } = data;
63
65
  const [tab, setTab] = useState<TabValue>(getInitialTab);
@@ -234,11 +236,13 @@ export function ResultsScreen({
234
236
  <FolderTree className="h-3 w-3 shrink-0" />
235
237
  Files
236
238
  </TabsTrigger>
237
- <TabsTrigger value="slides">
238
- <Presentation className="h-3 w-3 shrink-0" />
239
- Slides
240
- </TabsTrigger>
241
- {cartoonEnabled && (
239
+ {(!enabledPlugins || enabledPlugins.includes("slides")) && (
240
+ <TabsTrigger value="slides">
241
+ <Presentation className="h-3 w-3 shrink-0" />
242
+ Slides
243
+ </TabsTrigger>
244
+ )}
245
+ {(!enabledPlugins || enabledPlugins.includes("cartoon")) && (
242
246
  <TabsTrigger value="cartoon">
243
247
  <Sparkles className="h-3 w-3 shrink-0" />
244
248
  Comic
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { X, Check, Loader2 } from "lucide-react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { X, Check, Loader2, Search, ChevronDown } from "lucide-react";
3
3
 
4
4
  interface ConfigData {
5
5
  model: string;
@@ -10,6 +10,8 @@ interface ConfigData {
10
10
  concurrency: number;
11
11
  has_api_key: boolean;
12
12
  has_github_token: boolean;
13
+ enabled_plugins: string[];
14
+ available_plugins: Array<{ id: string; name: string }>;
13
15
  defaults: {
14
16
  model: string;
15
17
  language: string;
@@ -19,13 +21,13 @@ interface ConfigData {
19
21
  };
20
22
  }
21
23
 
22
- const MODELS = [
23
- "anthropic/claude-sonnet-4.6",
24
- "anthropic/claude-sonnet-4-20250514",
25
- "openai/gpt-4.1",
26
- "openai/o3",
27
- "google/gemini-2.5-pro-preview-06-05",
28
- ];
24
+ interface ModelInfo {
25
+ id: string;
26
+ name: string;
27
+ provider?: string;
28
+ created?: number;
29
+ contextLength?: number;
30
+ }
29
31
 
30
32
  const AGENTS = [
31
33
  { value: "", label: "Auto" },
@@ -39,17 +41,26 @@ const LANGUAGES = [
39
41
  "Spanish", "French", "German", "Portuguese",
40
42
  ];
41
43
 
42
- export function SettingsPanel({ onClose }: { onClose: () => void }) {
44
+ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => void; onFeaturesChange?: () => void }) {
43
45
  const [config, setConfig] = useState<ConfigData | null>(null);
44
46
  const [saving, setSaving] = useState(false);
45
47
  const [saved, setSaved] = useState(false);
46
48
  const [apiKeyInput, setApiKeyInput] = useState("");
47
49
  const [showApiKeyField, setShowApiKeyField] = useState(false);
50
+ const [models, setModels] = useState<ModelInfo[]>([]);
48
51
 
49
52
  useEffect(() => {
50
53
  fetch("/api/config")
51
54
  .then((r) => r.json())
52
- .then((data) => setConfig(data as ConfigData))
55
+ .then((data) => {
56
+ setConfig(data as ConfigData);
57
+ if ((data as ConfigData).has_api_key) {
58
+ fetch("/api/models")
59
+ .then((r) => r.json())
60
+ .then((m) => setModels(m as ModelInfo[]))
61
+ .catch(() => {});
62
+ }
63
+ })
53
64
  .catch(() => {});
54
65
  }, []);
55
66
 
@@ -67,10 +78,11 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
67
78
  setConfig(data as ConfigData);
68
79
  setSaved(true);
69
80
  setTimeout(() => setSaved(false), 2000);
81
+ if (update.enabled_plugins !== undefined) onFeaturesChange?.();
70
82
  } finally {
71
83
  setSaving(false);
72
84
  }
73
- }, []);
85
+ }, [onFeaturesChange]);
74
86
 
75
87
  if (!config) {
76
88
  return (
@@ -162,15 +174,15 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
162
174
 
163
175
  <Section title="Model">
164
176
  <Row label="LLM">
165
- <select
166
- value={config.model}
167
- onChange={(e) => save({ model: e.target.value })}
168
- className="h-7 rounded-md border bg-background px-2 text-[11px] font-mono focus:outline-none focus:border-foreground/20 cursor-pointer"
169
- >
170
- {MODELS.map((m) => (
171
- <option key={m} value={m}>{m.split("/").pop()}</option>
172
- ))}
173
- </select>
177
+ {config.has_api_key ? (
178
+ <ModelSelect
179
+ value={config.model}
180
+ models={models}
181
+ onChange={(id: string) => save({ model: id })}
182
+ />
183
+ ) : (
184
+ <span className="text-[11px] text-muted-foreground/40">Set API key first</span>
185
+ )}
174
186
  </Row>
175
187
  <Row label="Agent">
176
188
  <div className="flex gap-px rounded-md border p-0.5">
@@ -217,11 +229,151 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
217
229
  <NumberInput value={config.concurrency} onChange={(v) => save({ concurrency: v })} />
218
230
  </Row>
219
231
  </Section>
232
+
233
+ {config.available_plugins.length > 0 && (
234
+ <Section title="Plugins">
235
+ <div className="space-y-1">
236
+ {config.available_plugins.map((p) => {
237
+ const enabled = config.enabled_plugins.includes(p.id);
238
+ return (
239
+ <div key={p.id} className="flex items-center justify-between gap-3 py-1.5">
240
+ <div className="flex items-center gap-2 min-w-0">
241
+ <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${enabled ? "bg-green-500" : "bg-muted-foreground/20"}`} />
242
+ <span className="text-[11px] truncate">{p.name}</span>
243
+ </div>
244
+ <button
245
+ type="button"
246
+ onClick={() => {
247
+ const next = enabled
248
+ ? config.enabled_plugins.filter((id) => id !== p.id)
249
+ : [...config.enabled_plugins, p.id];
250
+ save({ enabled_plugins: next });
251
+ }}
252
+ className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors ${
253
+ enabled ? "bg-foreground" : "bg-muted"
254
+ }`}
255
+ >
256
+ <span className={`inline-block h-3 w-3 rounded-full bg-background transition-transform ${
257
+ enabled ? "translate-x-3.5" : "translate-x-0.5"
258
+ }`} />
259
+ </button>
260
+ </div>
261
+ );
262
+ })}
263
+ </div>
264
+ </Section>
265
+ )}
220
266
  </div>
221
267
  </div>
222
268
  );
223
269
  }
224
270
 
271
+ function ModelSelect({ value, models: initialModels, onChange }: { value: string; models: ModelInfo[]; onChange: (id: string) => void }) {
272
+ const [open, setOpen] = useState(false);
273
+ const [search, setSearch] = useState("");
274
+ const [models, setModels] = useState<ModelInfo[]>(initialModels);
275
+ const [loading, setLoading] = useState(false);
276
+ const ref = useRef<HTMLDivElement>(null);
277
+ const inputRef = useRef<HTMLInputElement>(null);
278
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
279
+
280
+ useEffect(() => { setModels(initialModels); }, [initialModels]);
281
+
282
+ useEffect(() => {
283
+ if (!open) return;
284
+ const handler = (e: MouseEvent) => {
285
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
286
+ };
287
+ document.addEventListener("mousedown", handler);
288
+ return () => document.removeEventListener("mousedown", handler);
289
+ }, [open]);
290
+
291
+ const fetchModels = useCallback((q: string) => {
292
+ setLoading(true);
293
+ fetch(`/api/models${q ? `?q=${encodeURIComponent(q)}` : ""}`)
294
+ .then((r) => r.json())
295
+ .then((data) => setModels(data as ModelInfo[]))
296
+ .catch(() => {})
297
+ .finally(() => setLoading(false));
298
+ }, []);
299
+
300
+ useEffect(() => {
301
+ if (open) {
302
+ setSearch("");
303
+ fetchModels("");
304
+ setTimeout(() => inputRef.current?.focus(), 0);
305
+ }
306
+ }, [open, fetchModels]);
307
+
308
+ const handleSearch = useCallback((q: string) => {
309
+ setSearch(q);
310
+ if (debounceRef.current) clearTimeout(debounceRef.current);
311
+ debounceRef.current = setTimeout(() => fetchModels(q), 300);
312
+ }, [fetchModels]);
313
+
314
+ const displayName = value.split("/").pop() ?? value;
315
+
316
+ return (
317
+ <div ref={ref} className="relative">
318
+ <button
319
+ type="button"
320
+ onClick={() => setOpen(!open)}
321
+ className="flex items-center gap-1.5 h-7 rounded-md border bg-background px-2.5 text-[11px] font-mono hover:border-foreground/20 transition-colors max-w-[220px]"
322
+ >
323
+ <span className="truncate flex-1 text-left">{displayName}</span>
324
+ <ChevronDown className={`h-3 w-3 text-muted-foreground/40 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
325
+ </button>
326
+ {open && (
327
+ <div className="absolute right-0 top-8 z-50 w-[320px] rounded-lg border bg-background shadow-lg">
328
+ <div className="p-1.5 border-b">
329
+ <div className="flex items-center gap-1.5 px-2 h-7 rounded-md bg-muted/50">
330
+ <Search className="h-3 w-3 text-muted-foreground/40 shrink-0" />
331
+ <input
332
+ ref={inputRef}
333
+ type="text"
334
+ value={search}
335
+ onChange={(e) => handleSearch(e.target.value)}
336
+ placeholder="Search models..."
337
+ className="flex-1 bg-transparent text-[11px] focus:outline-none placeholder:text-muted-foreground/30"
338
+ />
339
+ {loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/30 shrink-0" />}
340
+ </div>
341
+ </div>
342
+ <div className="max-h-[280px] overflow-y-auto p-1">
343
+ {models.length === 0 && !loading && (
344
+ <div className="px-2 py-3 text-center text-[11px] text-muted-foreground/40">No models found</div>
345
+ )}
346
+ {models.slice(0, 80).map((m, i) => {
347
+ const isSelected = m.id === value;
348
+ const provider = m.id.split("/")[0] ?? "";
349
+ const name = m.id.split("/").slice(1).join("/");
350
+ const prevProvider = i > 0 ? models[i - 1]!.id.split("/")[0] : null;
351
+ const showHeader = provider !== prevProvider;
352
+ return (
353
+ <div key={m.id}>
354
+ {showHeader && (
355
+ <div className="px-2 pt-2 pb-1 text-[10px] font-medium text-muted-foreground/30 uppercase tracking-wider">{provider}</div>
356
+ )}
357
+ <button
358
+ type="button"
359
+ onClick={() => { onChange(m.id); setOpen(false); }}
360
+ className={`w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
361
+ isSelected ? "bg-accent" : "hover:bg-accent/50"
362
+ }`}
363
+ >
364
+ <span className="text-[11px] font-mono truncate flex-1">{name}</span>
365
+ {isSelected && <Check className="h-3 w-3 text-foreground shrink-0" />}
366
+ </button>
367
+ </div>
368
+ );
369
+ })}
370
+ </div>
371
+ </div>
372
+ )}
373
+ </div>
374
+ );
375
+ }
376
+
225
377
  function Section({ title, children }: { title: string; children: React.ReactNode }) {
226
378
  return (
227
379
  <div>
@@ -1,19 +1,22 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useCallback } from "react";
2
2
 
3
3
  interface Features {
4
4
  cartoon: boolean;
5
5
  version: string;
6
+ enabledPlugins: string[];
6
7
  }
7
8
 
8
- export function useFeatures(): Features {
9
- const [features, setFeatures] = useState<Features>({ cartoon: false, version: "" });
9
+ export function useFeatures(): Features & { refresh: () => void } {
10
+ const [features, setFeatures] = useState<Features>({ cartoon: false, version: "", enabledPlugins: [] });
10
11
 
11
- useEffect(() => {
12
+ const refresh = useCallback(() => {
12
13
  fetch("/api/features")
13
14
  .then((r) => r.json())
14
15
  .then((data) => setFeatures(data as Features))
15
16
  .catch(() => {});
16
17
  }, []);
17
18
 
18
- return features;
19
+ useEffect(() => { refresh(); }, [refresh]);
20
+
21
+ return { ...features, refresh };
19
22
  }
@@ -6,6 +6,10 @@ export const SHIKI_LANGS = [
6
6
  "yaml", "html", "bash", "java", "c",
7
7
  "cpp", "ruby", "php", "swift", "kotlin",
8
8
  "sql", "markdown", "toml", "xml",
9
+ "csharp", "dart", "lua", "zig", "graphql",
10
+ "dockerfile", "prisma", "svelte", "vue",
11
+ "scss", "less", "r", "scala", "elixir",
12
+ "haskell", "ocaml", "perl",
9
13
  ] as const;
10
14
 
11
15
  export type ShikiLang = (typeof SHIKI_LANGS)[number];
@@ -35,20 +39,41 @@ const EXT_TO_LANG: Record<string, string> = {
35
39
  js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
36
40
  py: "python", pyi: "python",
37
41
  go: "go", rs: "rust",
38
- css: "css", scss: "css", less: "css",
42
+ css: "css", scss: "scss", less: "less",
39
43
  json: "json", jsonc: "json",
40
44
  yaml: "yaml", yml: "yaml",
41
- html: "html", htm: "html", vue: "html", svelte: "html",
45
+ html: "html", htm: "html",
46
+ vue: "vue", svelte: "svelte",
42
47
  sh: "bash", bash: "bash", zsh: "bash",
43
48
  java: "java", kt: "kotlin",
44
49
  c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp",
50
+ cs: "csharp",
45
51
  rb: "ruby", php: "php", swift: "swift",
46
52
  sql: "sql", md: "markdown", mdx: "markdown",
47
- toml: "toml",
53
+ toml: "toml", xml: "xml",
54
+ dart: "dart", lua: "lua", zig: "zig",
55
+ graphql: "graphql", gql: "graphql",
56
+ prisma: "prisma",
57
+ dockerfile: "dockerfile",
58
+ r: "r", R: "r",
59
+ scala: "scala", sc: "scala",
60
+ ex: "elixir", exs: "elixir",
61
+ hs: "haskell", ml: "ocaml",
62
+ pl: "perl", pm: "perl",
63
+ };
64
+
65
+ const NAME_TO_LANG: Record<string, string> = {
66
+ Dockerfile: "dockerfile",
67
+ Makefile: "bash",
68
+ Gemfile: "ruby",
69
+ Rakefile: "ruby",
48
70
  };
49
71
 
50
72
  export function detectShikiLang(filePath: string): ShikiLang | null {
51
- const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
73
+ const fileName = filePath.split("/").pop() ?? "";
74
+ const nameLang = NAME_TO_LANG[fileName];
75
+ if (nameLang) return nameLang as ShikiLang;
76
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
52
77
  return (EXT_TO_LANG[ext] as ShikiLang | undefined) ?? null;
53
78
  }
54
79
 
@@ -7,10 +7,11 @@ import { fetchPrDiff } from "../../github/fetch-diff.ts";
7
7
  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
- import { writeStoredConfig, type StoredConfig } from "../../config/store.ts";
10
+ import { readStoredConfig, writeStoredConfig, type StoredConfig } from "../../config/store.ts";
11
11
  import { startAnalysis, getSession, cancelAnalysis, subscribe, listActiveSessions } from "./session-manager.ts";
12
12
  import { generateCartoon } from "../../llm/cartoon.ts";
13
13
  import { generateSlides } from "../../llm/slides.ts";
14
+ import { getPlugin, getAllPlugins } from "../../plugins/registry.ts";
14
15
  import { chatWithTools, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
15
16
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
16
17
  import { randomBytes } from "node:crypto";
@@ -81,6 +82,14 @@ export function createRoutes(token: string, config: NewprConfig, options: RouteO
81
82
  }
82
83
  const slideJobs = new Map<string, SlideJob>();
83
84
 
85
+ interface PluginJob {
86
+ status: "running" | "done" | "error";
87
+ message: string;
88
+ current: number;
89
+ total: number;
90
+ }
91
+ const pluginJobs = new Map<string, PluginJob>();
92
+
84
93
  function buildChatSystemPrompt(data: NewprOutput): string {
85
94
  const fileSummaries = data.files
86
95
  .map((f) => `- ${f.path} (${f.status}, +${f.additions}/-${f.deletions}): ${f.summary}`)
@@ -229,6 +238,38 @@ $$
229
238
  },
230
239
  },
231
240
  },
241
+ {
242
+ type: "function",
243
+ function: {
244
+ name: "create_review_comment",
245
+ description: "Create an inline review comment on a specific line or line range of a file in this PR. The comment will be posted to GitHub. Use this when the user asks to leave a comment, suggestion, or feedback on specific code.",
246
+ parameters: {
247
+ type: "object",
248
+ properties: {
249
+ path: { type: "string", description: "File path (e.g. 'src/auth/session.ts')" },
250
+ line: { type: "number", description: "Line number to comment on (end line if range)" },
251
+ start_line: { type: "number", description: "Start line for multi-line comment (optional)" },
252
+ body: { type: "string", description: "Comment body in markdown" },
253
+ },
254
+ required: ["path", "line", "body"],
255
+ },
256
+ },
257
+ },
258
+ {
259
+ type: "function",
260
+ function: {
261
+ name: "submit_review",
262
+ description: "Submit a PR review with a verdict: APPROVE, REQUEST_CHANGES, or COMMENT. Use when the user asks to approve or request changes on the PR.",
263
+ parameters: {
264
+ type: "object",
265
+ properties: {
266
+ event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"], description: "Review action" },
267
+ body: { type: "string", description: "Optional review summary message" },
268
+ },
269
+ required: ["event"],
270
+ },
271
+ },
272
+ },
232
273
  ];
233
274
  }
234
275
 
@@ -483,7 +524,38 @@ $$
483
524
  }
484
525
  },
485
526
 
527
+ "GET /api/models": async () => {
528
+ if (!config.openrouter_api_key) return json([]);
529
+ try {
530
+ const res = await fetch("https://openrouter.ai/api/v1/models", {
531
+ headers: { Authorization: `Bearer ${config.openrouter_api_key}` },
532
+ });
533
+ if (!res.ok) return json([]);
534
+ const data = await res.json() as { data?: Array<{ id: string; name: string; created?: number; context_length?: number }> };
535
+ const models = (data.data ?? [])
536
+ .filter((m) => m.id && !m.id.includes(":free") && !m.id.includes(":extended"))
537
+ .map((m) => ({
538
+ id: m.id,
539
+ name: m.name ?? m.id,
540
+ provider: m.id.split("/")[0] ?? "",
541
+ created: m.created ?? 0,
542
+ contextLength: m.context_length,
543
+ }))
544
+ .sort((a, b) => {
545
+ const provCmp = a.provider.localeCompare(b.provider);
546
+ if (provCmp !== 0) return provCmp;
547
+ return b.created - a.created;
548
+ });
549
+ return json(models);
550
+ } catch {
551
+ return json([]);
552
+ }
553
+ },
554
+
486
555
  "GET /api/config": async () => {
556
+ const stored = await readStoredConfig();
557
+ const pluginList = getAllPlugins().map((p) => ({ id: p.id, name: p.name }));
558
+ const enabledPlugins = stored.enabled_plugins ?? pluginList.map((p) => p.id);
487
559
  return json({
488
560
  model: config.model,
489
561
  agent: config.agent ?? null,
@@ -493,6 +565,8 @@ $$
493
565
  concurrency: config.concurrency,
494
566
  has_api_key: !!config.openrouter_api_key,
495
567
  has_github_token: !!token,
568
+ enabled_plugins: enabledPlugins,
569
+ available_plugins: pluginList,
496
570
  defaults: {
497
571
  model: DEFAULT_CONFIG.model,
498
572
  language: DEFAULT_CONFIG.language,
@@ -540,14 +614,20 @@ $$
540
614
  update.concurrency = body.concurrency;
541
615
  config.concurrency = body.concurrency;
542
616
  }
617
+ if ((body as Record<string, unknown>).enabled_plugins !== undefined) {
618
+ update.enabled_plugins = (body as Record<string, unknown>).enabled_plugins as string[];
619
+ }
543
620
 
544
621
  await writeStoredConfig(update);
545
622
  return json({ ok: true });
546
623
  },
547
624
 
548
- "GET /api/features": () => {
625
+ "GET /api/features": async () => {
549
626
  const { getVersion } = require("../../version.ts");
550
- return json({ cartoon: !!options.cartoon, version: getVersion() });
627
+ const stored = await readStoredConfig();
628
+ const allPluginIds = getAllPlugins().map((p) => p.id);
629
+ const enabledPlugins = stored.enabled_plugins ?? allPluginIds;
630
+ return json({ cartoon: !!options.cartoon, version: getVersion(), enabledPlugins });
551
631
  },
552
632
 
553
633
  "POST /api/review": async (req: Request) => {
@@ -1096,6 +1176,67 @@ $$
1096
1176
  return `Fetch error: ${err instanceof Error ? err.message : String(err)}`;
1097
1177
  }
1098
1178
  }
1179
+ case "create_review_comment": {
1180
+ const filePath = args.path as string;
1181
+ const line = args.line as number;
1182
+ const startLine = args.start_line as number | undefined;
1183
+ const body = args.body as string;
1184
+ if (!filePath || !line || !body) return "Error: path, line, and body are required";
1185
+ try {
1186
+ const pr = parsePrInput(sessionData.meta.pr_url);
1187
+ const sha = await fetchHeadSha(pr);
1188
+ if (!sha) return "Error: could not determine HEAD SHA";
1189
+ const ghBody: Record<string, unknown> = {
1190
+ commit_id: sha,
1191
+ path: filePath,
1192
+ line,
1193
+ side: "RIGHT",
1194
+ body,
1195
+ };
1196
+ if (startLine && startLine !== line) {
1197
+ ghBody.start_line = startLine;
1198
+ ghBody.start_side = "RIGHT";
1199
+ }
1200
+ const res = await fetch(
1201
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments`,
1202
+ { method: "POST", headers: ghHeaders, body: JSON.stringify(ghBody) },
1203
+ );
1204
+ if (!res.ok) {
1205
+ const errBody = await res.text();
1206
+ return `GitHub API error ${res.status}: ${errBody.slice(0, 200)}`;
1207
+ }
1208
+ const data = await res.json() as { id?: number; html_url?: string };
1209
+ return `Comment created on ${filePath}:${startLine && startLine !== line ? `${startLine}-` : ""}${line}. ${data.html_url ?? ""}`;
1210
+ } catch (err) {
1211
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
1212
+ }
1213
+ }
1214
+ case "submit_review": {
1215
+ const event = args.event as string;
1216
+ const body = (args.body as string) ?? "";
1217
+ if (!event || !["APPROVE", "REQUEST_CHANGES", "COMMENT"].includes(event)) {
1218
+ return "Error: event must be APPROVE, REQUEST_CHANGES, or COMMENT";
1219
+ }
1220
+ try {
1221
+ const pr = parsePrInput(sessionData.meta.pr_url);
1222
+ const res = await fetch(
1223
+ `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews`,
1224
+ {
1225
+ method: "POST",
1226
+ headers: ghHeaders,
1227
+ body: JSON.stringify({ body, event }),
1228
+ },
1229
+ );
1230
+ if (!res.ok) {
1231
+ const errBody = await res.text();
1232
+ return `GitHub API error ${res.status}: ${errBody.slice(0, 200)}`;
1233
+ }
1234
+ const data = await res.json() as { html_url?: string; state?: string };
1235
+ return `Review submitted: ${data.state ?? event}. ${data.html_url ?? ""}`;
1236
+ } catch (err) {
1237
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
1238
+ }
1239
+ }
1099
1240
  default:
1100
1241
  return `Unknown tool: ${name}`;
1101
1242
  }
@@ -1310,5 +1451,83 @@ $$
1310
1451
  if (!job) return json({ status: "idle" });
1311
1452
  return json(job);
1312
1453
  },
1454
+
1455
+ "GET /api/plugins": () => {
1456
+ const plugins = getAllPlugins().map((p) => ({
1457
+ id: p.id,
1458
+ name: p.name,
1459
+ description: p.description,
1460
+ icon: p.icon,
1461
+ tabLabel: p.tabLabel,
1462
+ }));
1463
+ return json(plugins);
1464
+ },
1465
+
1466
+ "GET /api/plugins/:id/data": async (req: Request) => {
1467
+ const url = new URL(req.url);
1468
+ const segments = url.pathname.split("/");
1469
+ const pluginId = segments[3]!;
1470
+ const sessionId = url.searchParams.get("sessionId");
1471
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1472
+ const plugin = getPlugin(pluginId);
1473
+ if (!plugin) return json({ error: `Unknown plugin: ${pluginId}` }, 404);
1474
+ const data = await plugin.load(sessionId);
1475
+ return json(data);
1476
+ },
1477
+
1478
+ "POST /api/plugins/:id/generate": async (req: Request) => {
1479
+ const url = new URL(req.url);
1480
+ const segments = url.pathname.split("/");
1481
+ const pluginId = segments[3]!;
1482
+ const body = await req.json() as { sessionId?: string; resume?: boolean };
1483
+ const sessionId = body.sessionId;
1484
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1485
+ if (!config.openrouter_api_key) return json({ error: "API key required" }, 400);
1486
+
1487
+ const plugin = getPlugin(pluginId);
1488
+ if (!plugin) return json({ error: `Unknown plugin: ${pluginId}` }, 404);
1489
+
1490
+ const data = await loadSession(sessionId);
1491
+ if (!data) return json({ error: "Session not found" }, 404);
1492
+
1493
+ const jobKey = `${pluginId}:${sessionId}`;
1494
+ if (pluginJobs.has(jobKey) && pluginJobs.get(jobKey)!.status === "running") {
1495
+ return json({ status: "already_running" });
1496
+ }
1497
+
1498
+ const job: PluginJob = { status: "running", message: "Starting...", current: 0, total: 0 };
1499
+ pluginJobs.set(jobKey, job);
1500
+
1501
+ const existingData = body.resume ? await plugin.load(sessionId) : null;
1502
+
1503
+ (async () => {
1504
+ try {
1505
+ const result = await plugin.generate(
1506
+ { apiKey: config.openrouter_api_key, sessionId, data, language: config.language },
1507
+ (event) => { job.message = event.message; job.current = event.current; job.total = event.total; },
1508
+ existingData,
1509
+ );
1510
+ await plugin.save(sessionId, result.data);
1511
+ job.status = "done";
1512
+ job.message = "Complete";
1513
+ } catch (err) {
1514
+ job.status = "error";
1515
+ job.message = err instanceof Error ? err.message : String(err);
1516
+ }
1517
+ })();
1518
+
1519
+ return json({ status: "started" });
1520
+ },
1521
+
1522
+ "GET /api/plugins/:id/status": async (req: Request) => {
1523
+ const url = new URL(req.url);
1524
+ const segments = url.pathname.split("/");
1525
+ const pluginId = segments[3]!;
1526
+ const sessionId = url.searchParams.get("sessionId");
1527
+ if (!sessionId) return json({ error: "Missing sessionId" }, 400);
1528
+ const job = pluginJobs.get(`${pluginId}:${sessionId}`);
1529
+ if (!job) return json({ status: "idle" });
1530
+ return json(job);
1531
+ },
1313
1532
  };
1314
1533
  }