semantic-code-review 0.24.2__py3-none-any.whl

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 (100) hide show
  1. semantic_code_review/__init__.py +5 -0
  2. semantic_code_review/augment/__init__.py +3 -0
  3. semantic_code_review/augment/agents.py +95 -0
  4. semantic_code_review/augment/console.py +440 -0
  5. semantic_code_review/augment/extra_review.py +237 -0
  6. semantic_code_review/augment/fold_summary.py +447 -0
  7. semantic_code_review/augment/hunks.py +264 -0
  8. semantic_code_review/augment/mcp_server.py +156 -0
  9. semantic_code_review/augment/overview.py +233 -0
  10. semantic_code_review/augment/pass_.py +171 -0
  11. semantic_code_review/augment/pipeline.py +571 -0
  12. semantic_code_review/augment/progress.py +223 -0
  13. semantic_code_review/augment/prompts.py +93 -0
  14. semantic_code_review/augment/schemas.py +408 -0
  15. semantic_code_review/augment/tools.py +475 -0
  16. semantic_code_review/augment/trace_adapter.py +369 -0
  17. semantic_code_review/backends/__init__.py +95 -0
  18. semantic_code_review/backends/_cli_driver.py +571 -0
  19. semantic_code_review/backends/anthropic_sdk.py +56 -0
  20. semantic_code_review/backends/base.py +104 -0
  21. semantic_code_review/backends/claude_cli.py +421 -0
  22. semantic_code_review/backends/google_sdk.py +73 -0
  23. semantic_code_review/backends/openai_compat.py +42 -0
  24. semantic_code_review/cache/__init__.py +3 -0
  25. semantic_code_review/cache/store.py +87 -0
  26. semantic_code_review/cli/__init__.py +70 -0
  27. semantic_code_review/cli/_shared.py +157 -0
  28. semantic_code_review/cli/augment.py +74 -0
  29. semantic_code_review/cli/config_cmd.py +266 -0
  30. semantic_code_review/cli/fetch.py +33 -0
  31. semantic_code_review/cli/lint.py +27 -0
  32. semantic_code_review/cli/pr.py +98 -0
  33. semantic_code_review/cli/review.py +104 -0
  34. semantic_code_review/cli/runs_cmd.py +17 -0
  35. semantic_code_review/cli/show.py +19 -0
  36. semantic_code_review/cli/strip.py +20 -0
  37. semantic_code_review/config.py +563 -0
  38. semantic_code_review/config_template.py +154 -0
  39. semantic_code_review/fetch/__init__.py +61 -0
  40. semantic_code_review/fetch/anchor.py +244 -0
  41. semantic_code_review/fetch/github.py +229 -0
  42. semantic_code_review/fetch/github_comments.py +404 -0
  43. semantic_code_review/fetch/local.py +443 -0
  44. semantic_code_review/fetch/run_source.py +70 -0
  45. semantic_code_review/format/__init__.py +3 -0
  46. semantic_code_review/format/emit.py +163 -0
  47. semantic_code_review/format/lint.py +74 -0
  48. semantic_code_review/format/parse.py +574 -0
  49. semantic_code_review/format/sidecar.py +19 -0
  50. semantic_code_review/format/strip.py +20 -0
  51. semantic_code_review/git_ops.py +252 -0
  52. semantic_code_review/paths.py +61 -0
  53. semantic_code_review/review/__init__.py +3 -0
  54. semantic_code_review/review/comments.py +176 -0
  55. semantic_code_review/review/github.py +284 -0
  56. semantic_code_review/review/github_graphql.py +433 -0
  57. semantic_code_review/review/pr_flow.py +299 -0
  58. semantic_code_review/review/runner.py +407 -0
  59. semantic_code_review/review/server.py +1043 -0
  60. semantic_code_review/structural/__init__.py +41 -0
  61. semantic_code_review/structural/diff.py +102 -0
  62. semantic_code_review/structural/parse.py +330 -0
  63. semantic_code_review/structural/symbols.py +43 -0
  64. semantic_code_review/viewer/__init__.py +3 -0
  65. semantic_code_review/viewer/assets/annotations.ts +547 -0
  66. semantic_code_review/viewer/assets/boot.ts +273 -0
  67. semantic_code_review/viewer/assets/comment_store.ts +121 -0
  68. semantic_code_review/viewer/assets/comments.ts +624 -0
  69. semantic_code_review/viewer/assets/console.ts +426 -0
  70. semantic_code_review/viewer/assets/console_render.ts +213 -0
  71. semantic_code_review/viewer/assets/console_selection.ts +102 -0
  72. semantic_code_review/viewer/assets/data_store.ts +156 -0
  73. semantic_code_review/viewer/assets/file_rows.ts +49 -0
  74. semantic_code_review/viewer/assets/folds.ts +560 -0
  75. semantic_code_review/viewer/assets/index.html +62 -0
  76. semantic_code_review/viewer/assets/post_modal.ts +383 -0
  77. semantic_code_review/viewer/assets/progress.ts +138 -0
  78. semantic_code_review/viewer/assets/render.ts +937 -0
  79. semantic_code_review/viewer/assets/sidebar.ts +429 -0
  80. semantic_code_review/viewer/assets/sse.ts +78 -0
  81. semantic_code_review/viewer/assets/text_highlight.ts +215 -0
  82. semantic_code_review/viewer/assets/types.d.ts +388 -0
  83. semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
  84. semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
  85. semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
  86. semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
  87. semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
  88. semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
  89. semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
  90. semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
  91. semantic_code_review/viewer/assets/viewer.css +1202 -0
  92. semantic_code_review/viewer/assets/viewer.js +10588 -0
  93. semantic_code_review/viewer/build_json.py +546 -0
  94. semantic_code_review/viewer/hunk_layout.py +471 -0
  95. semantic_code_review-0.24.2.dist-info/METADATA +348 -0
  96. semantic_code_review-0.24.2.dist-info/RECORD +100 -0
  97. semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
  98. semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
  99. semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
  100. semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,426 @@
1
+ // Review console — bottom-bar Q&A over the change under review.
2
+ //
3
+ // Slice 2 (ADR 0002): streaming turns over SSE, with cancel. A
4
+ // persistent, unobtrusive prompt input shares the footer with the
5
+ // status counts (right); `Ctrl-P` focuses it (intercepting the browser
6
+ // Print shortcut — acceptable on a dedicated localhost tab). On submit
7
+ // it POSTs /console/ask (which returns 202) and then drives the
8
+ // transcript off the SSE stream: `console-delta` chunks accumulate into
9
+ // the answer, `console-tool` frames surface tool activity, and a
10
+ // terminal `console-done` / `console-error` ends the turn. A Stop
11
+ // affordance (and `Esc`) cancels an in-flight turn via /console/cancel;
12
+ // a second `Esc` collapses the drawer and drops the conversation.
13
+ //
14
+ // The conversation `message_history` lives server-side and is
15
+ // ephemeral; this module only holds the visible transcript. Every
16
+ // console frame is tagged with a per-tab `console_id` so streams from
17
+ // other tabs are ignored, and the frames are unbuffered server-side, so
18
+ // a mid-turn reload starts the console fresh. Answers render as markdown
19
+ // (with inline mermaid) via `renderConsoleMarkdown`, with raw HTML
20
+ // disabled and the output sanitised, so `<script>`-laden model output is
21
+ // neutralised.
22
+ //
23
+ // Slice 4 adds selection-awareness: the reviewer's page selection is
24
+ // resolved (`console_selection.ts`) to a code / comment / plain hint,
25
+ // shown as a clearable chip, and folded once into the turn it's
26
+ // submitted with — turn-anchored, never re-injected.
27
+
28
+ import { renderConsoleMarkdown } from "./console_render";
29
+ import { resolveSelection, type ConsoleSelection } from "./console_selection";
30
+
31
+ let _endpoint = "";
32
+ let _consoleId = "";
33
+ let _drawer: HTMLElement | null = null;
34
+ let _transcript: HTMLElement | null = null;
35
+ let _input: HTMLTextAreaElement | null = null;
36
+ let _stop: HTMLButtonElement | null = null;
37
+ let _chip: HTMLElement | null = null;
38
+ let _busy = false;
39
+ // The console asker is only wired server-side once augmentation
40
+ // completes (it grounds answers in the on-disk sidecar — see runner's
41
+ // `done` publish). Until then /console/ask 409s, so the input stays
42
+ // disabled with an explanatory placeholder rather than letting the
43
+ // reviewer fire a confusing error. Flipped by `markReady` on the
44
+ // augment-complete `done` event.
45
+ let _ready = true;
46
+
47
+ const READY_PLACEHOLDER = "Ask about this change… (Ctrl-P)";
48
+ const PENDING_PLACEHOLDER = "Console available once analysis finishes…";
49
+
50
+ // The reviewer's pinned selection (Slice 4), tracked live off the page's
51
+ // selection and folded into the next turn. Set when a usable selection
52
+ // is made over the change, replaced on re-select, cleared on submit (or
53
+ // via the chip's × ). Never auto-cleared on collapse — clicking into the
54
+ // prompt deselects the diff visually, but the pin persists.
55
+ let _selection: ConsoleSelection | null = null;
56
+
57
+ // The in-flight turn's DOM + accumulated answer text. Null between
58
+ // turns; the SSE handlers no-op when there's nothing in flight.
59
+ interface PendingTurn {
60
+ answer: HTMLElement;
61
+ activity: HTMLElement;
62
+ text: HTMLElement;
63
+ accumulated: string;
64
+ }
65
+ let _pending: PendingTurn | null = null;
66
+
67
+ const MAX_INPUT_ROWS = 6;
68
+
69
+ /** A per-tab id stamped on every /console request and matched against
70
+ * every console SSE frame, so a tab ignores another tab's stream. */
71
+ function genConsoleId(): string {
72
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
73
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
74
+ return "c" + Math.random().toString(36).slice(2) + Date.now().toString(36);
75
+ }
76
+
77
+ /** Mount the console bar into the footer. No-op (returns) when the
78
+ * footer is absent — e.g. a DOM that doesn't include the status bar. */
79
+ function init(endpoint: string, opts: { ready?: boolean } = {}): void {
80
+ _endpoint = endpoint;
81
+ _consoleId = genConsoleId();
82
+ // The console is unusable until augmentation lands (see `_ready`).
83
+ // The caller knows the augment state from the initial data payload;
84
+ // a page opened after augmentation already completed is ready at once.
85
+ _ready = opts.ready ?? true;
86
+ const footer = document.getElementById("status-bar");
87
+ if (!footer || _input) return; // idempotent: don't double-mount
88
+ build(footer);
89
+ wireGlobalKeys();
90
+ }
91
+
92
+ /** Enable the console once augmentation completes (the augment-complete
93
+ * `done` SSE event), at which point the server has installed the asker.
94
+ * Idempotent: a page that booted post-augment is already ready. */
95
+ function markReady(): void {
96
+ _ready = true;
97
+ if (_input) {
98
+ _input.disabled = false;
99
+ _input.placeholder = READY_PLACEHOLDER;
100
+ }
101
+ }
102
+
103
+ function build(footer: HTMLElement): void {
104
+ footer.classList.add("has-console");
105
+
106
+ // Transcript drawer: fixed above the footer, hidden until the first
107
+ // turn. Grows upward (CSS caps its height, then it scrolls).
108
+ _drawer = document.createElement("div");
109
+ _drawer.className = "console-drawer hidden";
110
+ _transcript = document.createElement("div");
111
+ _transcript.className = "console-transcript";
112
+ _drawer.appendChild(_transcript);
113
+ document.body.appendChild(_drawer);
114
+
115
+ // Selection chip: shows the reviewer's pinned selection, with a × to
116
+ // clear it. Hidden until a selection is pinned; sits at the far left of
117
+ // the footer, ahead of the prompt.
118
+ _chip = document.createElement("div");
119
+ _chip.className = "console-chip hidden";
120
+ footer.insertBefore(_chip, footer.firstChild);
121
+
122
+ // Prompt input on the left of the footer, ahead of the status counts.
123
+ _input = document.createElement("textarea");
124
+ _input.className = "console-input";
125
+ _input.rows = 1;
126
+ _input.placeholder = _ready ? READY_PLACEHOLDER : PENDING_PLACEHOLDER;
127
+ _input.disabled = !_ready;
128
+ _input.setAttribute("aria-label", "Review console prompt");
129
+ footer.insertBefore(_input, _chip.nextSibling);
130
+
131
+ // Stop affordance: hidden until a turn is in flight, sits between the
132
+ // prompt and the status counts.
133
+ _stop = document.createElement("button");
134
+ _stop.className = "console-stop hidden";
135
+ _stop.type = "button";
136
+ _stop.textContent = "Stop";
137
+ _stop.title = "Cancel the in-flight answer (Esc)";
138
+ _stop.addEventListener("click", () => cancelTurn());
139
+ footer.insertBefore(_stop, _input.nextSibling);
140
+
141
+ _input.addEventListener("input", autogrow);
142
+ _input.addEventListener("keydown", onInputKey);
143
+ // Focusing the prompt reveals the chip for a selection made just
144
+ // before the click; the selectionchange tracker keeps it current.
145
+ _input.addEventListener("focus", renderChip);
146
+ document.addEventListener("selectionchange", onSelectionChange);
147
+ }
148
+
149
+ // Track the page selection. A usable selection over the change pins
150
+ // itself (replacing any prior pin); a collapse — e.g. clicking into the
151
+ // prompt — is ignored so the pin survives until submit or an explicit
152
+ // clear. Selections inside the console UI resolve to null and are
153
+ // likewise ignored.
154
+ function onSelectionChange(): void {
155
+ const sel = resolveSelection(
156
+ typeof window.getSelection === "function" ? window.getSelection() : null,
157
+ );
158
+ if (!sel) return;
159
+ _selection = sel;
160
+ renderChip();
161
+ }
162
+
163
+ // (Re)paint the chip from `_selection`. Hidden when nothing is pinned.
164
+ function renderChip(): void {
165
+ if (!_chip) return;
166
+ if (!_selection) {
167
+ _chip.classList.add("hidden");
168
+ _chip.textContent = "";
169
+ return;
170
+ }
171
+ _chip.classList.remove("hidden");
172
+ _chip.textContent = "";
173
+ const label = document.createElement("span");
174
+ label.className = "console-chip-label";
175
+ label.textContent = chipLabel(_selection);
176
+ label.title = _selection.selection_text;
177
+ const clear = document.createElement("button");
178
+ clear.type = "button";
179
+ clear.className = "console-chip-clear";
180
+ clear.textContent = "×";
181
+ clear.title = "Clear selection";
182
+ clear.addEventListener("click", (e) => {
183
+ e.preventDefault();
184
+ clearSelection();
185
+ });
186
+ _chip.appendChild(label);
187
+ _chip.appendChild(clear);
188
+ }
189
+
190
+ // A compact one-line description of the pinned selection for the chip.
191
+ function chipLabel(sel: ConsoleSelection): string {
192
+ if (sel.selection_kind === "code" && sel.file) {
193
+ const [lo, hi] = sel.line_range || [0, 0];
194
+ const span = lo && hi ? (lo === hi ? `:${lo}` : `:${lo}–${hi}`) : "";
195
+ return `${sel.file}${span}`;
196
+ }
197
+ const snippet = sel.selection_text.replace(/\s+/g, " ").slice(0, 40);
198
+ const kind = sel.selection_kind === "comment" ? "comment" : "text";
199
+ return `${kind}: “${snippet}${sel.selection_text.length > 40 ? "…" : ""}”`;
200
+ }
201
+
202
+ function clearSelection(): void {
203
+ _selection = null;
204
+ renderChip();
205
+ }
206
+
207
+ function wireGlobalKeys(): void {
208
+ window.addEventListener("keydown", (e: KeyboardEvent) => {
209
+ // Ctrl-P / Cmd-P focuses the prompt, suppressing the print dialog.
210
+ if ((e.ctrlKey || e.metaKey) && (e.key === "p" || e.key === "P")) {
211
+ e.preventDefault();
212
+ reveal();
213
+ _input?.focus();
214
+ }
215
+ });
216
+ }
217
+
218
+ function onInputKey(e: KeyboardEvent): void {
219
+ if (e.key === "Enter" && !e.shiftKey) {
220
+ e.preventDefault();
221
+ void submit();
222
+ } else if (e.key === "Escape") {
223
+ e.preventDefault();
224
+ // Esc cancels an in-flight turn first; only once nothing is running
225
+ // does it collapse the drawer and drop the conversation.
226
+ if (_busy) cancelTurn();
227
+ else dismiss();
228
+ }
229
+ }
230
+
231
+ // Grow the textarea with its content, 1 → MAX_INPUT_ROWS lines, then
232
+ // let it scroll. Measured off scrollHeight after a reset to auto.
233
+ function autogrow(): void {
234
+ if (!_input) return;
235
+ _input.style.height = "auto";
236
+ const line = parseFloat(getComputedStyle(_input).lineHeight) || 18;
237
+ const max = line * MAX_INPUT_ROWS;
238
+ _input.style.height = Math.min(_input.scrollHeight, max) + "px";
239
+ }
240
+
241
+ async function submit(): Promise<void> {
242
+ if (!_input || _busy) return;
243
+ const question = _input.value.trim();
244
+ if (!question) return;
245
+ // The selection is turn-anchored: snapshot it for this turn, then drop
246
+ // the pin so it never bleeds into the next question.
247
+ const selection = _selection;
248
+ _input.value = "";
249
+ autogrow();
250
+ clearSelection();
251
+ reveal();
252
+ appendQuestion(question);
253
+ _pending = appendAnswer();
254
+ setBusy(true);
255
+ try {
256
+ const body: Record<string, unknown> = { question, console_id: _consoleId };
257
+ if (selection) body.selection = selection;
258
+ const r = await fetch(`${_endpoint}/console/ask`, {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify(body),
262
+ });
263
+ // 202 means the turn was accepted and is streaming over SSE; the
264
+ // answer arrives via onDelta/onDone, not this response body. Any
265
+ // other status is an immediate failure (409 busy / unavailable).
266
+ // No SSE terminal frame is coming for these, so end the turn here —
267
+ // otherwise the console stays stuck "busy" (Stop pinned, input
268
+ // locked) and the reviewer can never ask again.
269
+ if (!r.ok && r.status !== 202) {
270
+ failPending(await errorText(r));
271
+ endTurn();
272
+ }
273
+ } catch (e) {
274
+ failPending(`request failed: ${e}`);
275
+ endTurn();
276
+ } finally {
277
+ scrollToEnd();
278
+ }
279
+ }
280
+
281
+ // Stop / Esc-while-busy: ask the server to cancel. The turn ends when
282
+ // the worker emits a cancelled `console-done`; we keep the partial
283
+ // answer that already streamed.
284
+ function cancelTurn(): void {
285
+ if (!_busy) return;
286
+ if (_stop) _stop.disabled = true;
287
+ fetch(`${_endpoint}/console/cancel`, {
288
+ method: "POST",
289
+ headers: { "Content-Type": "application/json" },
290
+ body: JSON.stringify({ console_id: _consoleId }),
291
+ }).catch(() => { /* server may be tearing down; cancel is best-effort */ });
292
+ }
293
+
294
+ // Esc with nothing in flight: drop the conversation and collapse. The
295
+ // server-side history is ephemeral; resetting it means the next turn
296
+ // re-seeds from scratch.
297
+ function dismiss(): void {
298
+ _input?.blur();
299
+ collapse();
300
+ if (_transcript) _transcript.textContent = "";
301
+ _pending = null;
302
+ fetch(`${_endpoint}/console/reset`, {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: "{}",
306
+ }).catch(() => { /* server may be tearing down; reset is best-effort */ });
307
+ }
308
+
309
+ // --- SSE handlers (filtered by console_id) -------------------------------
310
+
311
+ function mine(payload: { console_id?: string }): boolean {
312
+ return !!payload && payload.console_id === _consoleId;
313
+ }
314
+
315
+ function onDelta(payload: SseConsoleDeltaEvent): void {
316
+ if (!mine(payload) || !_pending) return;
317
+ _pending.accumulated += payload.text || "";
318
+ renderConsoleMarkdown(_pending.text, _pending.accumulated);
319
+ scrollToEnd();
320
+ }
321
+
322
+ function onTool(payload: SseConsoleToolEvent): void {
323
+ if (!mine(payload) || !_pending) return;
324
+ const line = document.createElement("div");
325
+ line.className = "console-tool";
326
+ line.textContent = payload.label || "";
327
+ _pending.activity.appendChild(line);
328
+ scrollToEnd();
329
+ }
330
+
331
+ function onDone(payload: SseConsoleDoneEvent): void {
332
+ if (!mine(payload)) return;
333
+ const pending = _pending;
334
+ if (pending) {
335
+ // Backends that don't stream deltas (CLI, Slice 5) carry the whole
336
+ // answer on `done`; fall back to it when nothing streamed.
337
+ if (!pending.accumulated && payload.answer) {
338
+ renderConsoleMarkdown(pending.text, payload.answer);
339
+ }
340
+ if (payload.cancelled) pending.answer.classList.add("console-cancelled");
341
+ else if (!pending.accumulated && !payload.answer) {
342
+ pending.text.textContent = "(empty answer)";
343
+ }
344
+ }
345
+ endTurn();
346
+ }
347
+
348
+ function onError(payload: SseConsoleErrorEvent): void {
349
+ if (!mine(payload)) return;
350
+ failPending(payload.error || "console error");
351
+ endTurn();
352
+ }
353
+
354
+ // --- transcript rendering ------------------------------------------------
355
+
356
+ function appendQuestion(text: string): void {
357
+ if (!_transcript) return;
358
+ const q = document.createElement("div");
359
+ q.className = "console-q";
360
+ q.textContent = text; // plain text — never interpreted as HTML
361
+ _transcript.appendChild(q);
362
+ scrollToEnd();
363
+ }
364
+
365
+ function appendAnswer(): PendingTurn {
366
+ const answer = document.createElement("div");
367
+ answer.className = "console-a console-pending";
368
+ const activity = document.createElement("div");
369
+ activity.className = "console-activity";
370
+ const text = document.createElement("div");
371
+ text.className = "console-text";
372
+ text.textContent = "…";
373
+ answer.appendChild(activity);
374
+ answer.appendChild(text);
375
+ _transcript?.appendChild(answer);
376
+ scrollToEnd();
377
+ return { answer, activity, text, accumulated: "" };
378
+ }
379
+
380
+ // Mark the in-flight answer as failed and show the error in place of
381
+ // whatever (if anything) had streamed.
382
+ function failPending(message: string): void {
383
+ if (!_pending) return;
384
+ _pending.answer.classList.add("console-error");
385
+ _pending.text.textContent = message;
386
+ }
387
+
388
+ async function errorText(r: Response): Promise<string> {
389
+ try {
390
+ const body = (await r.json()) as { error?: string };
391
+ if (body && body.error) return body.error;
392
+ } catch (_) { /* fall through to status text */ }
393
+ return `request failed (${r.status})`;
394
+ }
395
+
396
+ // Clear the busy state and detach the in-flight turn. The pending
397
+ // marker comes off so the answer renders in its final style.
398
+ function endTurn(): void {
399
+ if (_pending) _pending.answer.classList.remove("console-pending");
400
+ _pending = null;
401
+ setBusy(false);
402
+ scrollToEnd();
403
+ }
404
+
405
+ function setBusy(busy: boolean): void {
406
+ _busy = busy;
407
+ if (_input) _input.classList.toggle("busy", busy);
408
+ if (_stop) {
409
+ _stop.classList.toggle("hidden", !busy);
410
+ _stop.disabled = false;
411
+ }
412
+ }
413
+
414
+ function reveal(): void {
415
+ _drawer?.classList.remove("hidden");
416
+ }
417
+
418
+ function collapse(): void {
419
+ _drawer?.classList.add("hidden");
420
+ }
421
+
422
+ function scrollToEnd(): void {
423
+ if (_drawer) _drawer.scrollTop = _drawer.scrollHeight;
424
+ }
425
+
426
+ export const Console = { init, markReady, onDelta, onTool, onDone, onError };
@@ -0,0 +1,213 @@
1
+ // Review-console answer rendering — markdown + inline mermaid.
2
+ //
3
+ // Slice 3 (ADR 0002): console answers are markdown, rendered fully
4
+ // client-side. The answer buffer is re-rendered on every streamed delta
5
+ // with markdown-it (raw HTML disabled) and the output run through
6
+ // DOMPurify — model output is repo-sourced, so a malicious repo can try
7
+ // to prompt-inject `<script>` / `<img onerror>`, and localhost is not a
8
+ // safe boundary. Non-mermaid code fences are highlighted with the
9
+ // vendored hljs (the same global the diff cells use).
10
+ //
11
+ // mermaid is MB-class and rarely used, so it is not bundled into
12
+ // viewer.js: it is lazy-loaded by `<script>` injection the first time an
13
+ // answer *completes* a `mermaid` fence. Diagrams render with
14
+ // `securityLevel: 'strict'`; an invalid diagram (or a mermaid that
15
+ // fails to load) degrades to its raw source block — never an error box.
16
+
17
+ import MarkdownIt from "markdown-it";
18
+ import DOMPurify from "dompurify";
19
+
20
+ // hljs is loaded as a classic script (vendored) and exposed as a global,
21
+ // exactly as render.ts consumes it for diff cells.
22
+ interface Hljs {
23
+ highlight(code: string, opts: { language: string; ignoreIllegals: boolean }): { value: string };
24
+ getLanguage(name: string): unknown;
25
+ }
26
+ function hljs(): Hljs | undefined {
27
+ return (window as unknown as { hljs?: Hljs }).hljs;
28
+ }
29
+
30
+ const md: MarkdownIt = new MarkdownIt({
31
+ html: false, // never interpret raw HTML in model output
32
+ linkify: true,
33
+ highlight(code: string, lang: string): string {
34
+ // mermaid and unknown languages fall back to markdown-it's own
35
+ // escaping (`<pre><code class="language-…">`). For mermaid this
36
+ // `<pre>` is exactly the fallback we keep when no diagram renders.
37
+ if (!lang || lang.toLowerCase() === "mermaid") return "";
38
+ const h = hljs();
39
+ if (h && h.getLanguage(lang)) {
40
+ try {
41
+ const out = h.highlight(code, { language: lang, ignoreIllegals: true }).value;
42
+ return `<pre class="hljs"><code class="language-${lang}">${out}</code></pre>`;
43
+ } catch {
44
+ /* fall through to markdown-it's default escaping */
45
+ }
46
+ }
47
+ return "";
48
+ },
49
+ });
50
+
51
+ /** Render the accumulated answer markdown into `target`, then paint any
52
+ * completed mermaid fences. Safe to call on every delta — it fully
53
+ * replaces `target`'s content each time. */
54
+ export function renderConsoleMarkdown(target: HTMLElement, markdown: string): void {
55
+ target.innerHTML = DOMPurify.sanitize(md.render(markdown));
56
+ paintMermaid(target, markdown);
57
+ }
58
+
59
+ // --- mermaid -------------------------------------------------------------
60
+
61
+ interface MermaidApi {
62
+ initialize(cfg: Record<string, unknown>): void;
63
+ render(id: string, src: string): Promise<{ svg: string }>;
64
+ }
65
+
66
+ // Source → rendered SVG, so a diagram is rendered once and re-injected
67
+ // synchronously on later deltas (no flicker, no re-render churn).
68
+ const mermaidSvgCache = new Map<string, string>();
69
+ // Sources that mermaid rejected as invalid — kept as raw source forever
70
+ // rather than re-attempted on every delta.
71
+ const mermaidFailed = new Set<string>();
72
+ let mermaidSeq = 0;
73
+ let mermaidLoad: Promise<MermaidApi | null> | null = null;
74
+
75
+ /** Mermaid's built-in theme matching the viewer's active colour scheme.
76
+ * Mirrors the CSS cascade in viewer.css: `:root` is dark by default and
77
+ * only a `prefers-color-scheme: light` match flips it to light. So pick
78
+ * the light ("default") theme only when that query matches; otherwise
79
+ * dark. Without this, mermaid renders its light theme over the (default)
80
+ * dark page — light elements on a transparent background, unreadable. */
81
+ function activeMermaidTheme(): "dark" | "default" {
82
+ const mq =
83
+ typeof window.matchMedia === "function"
84
+ ? window.matchMedia("(prefers-color-scheme: light)")
85
+ : null;
86
+ return mq && mq.matches ? "default" : "dark";
87
+ }
88
+
89
+ function initMermaid(m: MermaidApi): void {
90
+ // `htmlLabels: false` is load-bearing, not cosmetic. Mermaid's default
91
+ // renders node labels as HTML inside an `<foreignObject>`; DOMPurify
92
+ // (both our defence-in-depth pass in swapInSvg and its default policy)
93
+ // strips `<foreignObject>` wholesale as an mXSS / namespace-confusion
94
+ // vector, which silently removes every node label. Forcing SVG-native
95
+ // `<text>`/`<tspan>` labels — which survive the sanitizer — fixes that
96
+ // without widening the sanitizer to allow arbitrary HTML in untrusted
97
+ // (repo-sourced) diagram output.
98
+ m.initialize({
99
+ startOnLoad: false,
100
+ securityLevel: "strict",
101
+ theme: activeMermaidTheme(),
102
+ htmlLabels: false,
103
+ flowchart: { htmlLabels: false },
104
+ });
105
+ }
106
+
107
+ /** Inject the vendored mermaid bundle once and resolve to its global.
108
+ * Resolves to null if the script can't load — the caller then leaves
109
+ * the raw source in place. Memoised, so deltas never double-inject. */
110
+ function loadMermaid(): Promise<MermaidApi | null> {
111
+ if (mermaidLoad) return mermaidLoad;
112
+ mermaidLoad = new Promise<MermaidApi | null>((resolve) => {
113
+ const existing = (window as unknown as { mermaid?: MermaidApi }).mermaid;
114
+ if (existing) {
115
+ initMermaid(existing);
116
+ resolve(existing);
117
+ return;
118
+ }
119
+ const s = document.createElement("script");
120
+ s.src = "/static/vendor/mermaid.min.js";
121
+ s.async = true;
122
+ s.onload = () => {
123
+ const m = (window as unknown as { mermaid?: MermaidApi }).mermaid;
124
+ if (m) {
125
+ initMermaid(m);
126
+ resolve(m);
127
+ } else {
128
+ resolve(null);
129
+ }
130
+ };
131
+ s.onerror = () => resolve(null);
132
+ document.head.appendChild(s);
133
+ });
134
+ return mermaidLoad;
135
+ }
136
+
137
+ /** A fence per mermaid block in source order, flagged closed/unclosed.
138
+ * Mirrors markdown-it's fence scan closely enough to keep 1:1 ordering
139
+ * with the rendered `code.language-mermaid` blocks: every fence (any
140
+ * language) is consumed so sequential fences stay aligned, but only the
141
+ * mermaid ones are recorded. An unclosed fence (still streaming) is
142
+ * left as source until its closer arrives. */
143
+ export function scanMermaidFences(src: string): { closed: boolean }[] {
144
+ const lines = src.split("\n");
145
+ const blocks: { closed: boolean }[] = [];
146
+ let i = 0;
147
+ while (i < lines.length) {
148
+ const open = /^[ \t]*(`{3,}|~{3,})\s*([^\s`~]*)/.exec(lines[i]);
149
+ if (!open) {
150
+ i++;
151
+ continue;
152
+ }
153
+ const marker = open[1][0];
154
+ const len = open[1].length;
155
+ const isMermaid = open[2].toLowerCase() === "mermaid";
156
+ let j = i + 1;
157
+ let closed = false;
158
+ for (; j < lines.length; j++) {
159
+ const close = /^[ \t]*(`{3,}|~{3,})\s*$/.exec(lines[j]);
160
+ if (close && close[1][0] === marker && close[1].length >= len) {
161
+ closed = true;
162
+ break;
163
+ }
164
+ }
165
+ if (isMermaid) blocks.push({ closed });
166
+ i = closed ? j + 1 : lines.length;
167
+ }
168
+ return blocks;
169
+ }
170
+
171
+ function swapInSvg(pre: HTMLElement, svg: string): void {
172
+ const fig = document.createElement("div");
173
+ fig.className = "console-mermaid";
174
+ // svg is mermaid's strict-mode output; sanitise once more as defence
175
+ // in depth since the diagram text itself is untrusted model output.
176
+ fig.innerHTML = DOMPurify.sanitize(svg);
177
+ pre.replaceWith(fig);
178
+ }
179
+
180
+ function paintMermaid(target: HTMLElement, markdown: string): void {
181
+ const fences = scanMermaidFences(markdown);
182
+ if (fences.length === 0) return;
183
+ const codes = target.querySelectorAll<HTMLElement>("pre > code.language-mermaid");
184
+ codes.forEach((code, idx) => {
185
+ const fence = fences[idx];
186
+ if (!fence || !fence.closed) return; // still streaming → leave source
187
+ const src = (code.textContent || "").replace(/\n$/, "");
188
+ if (mermaidFailed.has(src)) return; // invalid → leave source
189
+ const pre = code.parentElement;
190
+ if (!pre) return;
191
+ const cached = mermaidSvgCache.get(src);
192
+ if (cached) {
193
+ swapInSvg(pre, cached);
194
+ return;
195
+ }
196
+ void renderOne(src, target, markdown);
197
+ });
198
+ }
199
+
200
+ async function renderOne(src: string, target: HTMLElement, markdown: string): Promise<void> {
201
+ const m = await loadMermaid();
202
+ if (!m) return; // couldn't load mermaid → leave source (may retry on reload)
203
+ try {
204
+ const { svg } = await m.render("scr-mermaid-" + mermaidSeq++, src);
205
+ mermaidSvgCache.set(src, svg);
206
+ } catch {
207
+ mermaidFailed.add(src);
208
+ }
209
+ // A newer delta may have replaced target.innerHTML while we awaited;
210
+ // re-paint the live DOM so the freshly-cached (or now-failed) block
211
+ // settles against whatever is on screen now.
212
+ paintMermaid(target, markdown);
213
+ }