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.
- semantic_code_review/__init__.py +5 -0
- semantic_code_review/augment/__init__.py +3 -0
- semantic_code_review/augment/agents.py +95 -0
- semantic_code_review/augment/console.py +440 -0
- semantic_code_review/augment/extra_review.py +237 -0
- semantic_code_review/augment/fold_summary.py +447 -0
- semantic_code_review/augment/hunks.py +264 -0
- semantic_code_review/augment/mcp_server.py +156 -0
- semantic_code_review/augment/overview.py +233 -0
- semantic_code_review/augment/pass_.py +171 -0
- semantic_code_review/augment/pipeline.py +571 -0
- semantic_code_review/augment/progress.py +223 -0
- semantic_code_review/augment/prompts.py +93 -0
- semantic_code_review/augment/schemas.py +408 -0
- semantic_code_review/augment/tools.py +475 -0
- semantic_code_review/augment/trace_adapter.py +369 -0
- semantic_code_review/backends/__init__.py +95 -0
- semantic_code_review/backends/_cli_driver.py +571 -0
- semantic_code_review/backends/anthropic_sdk.py +56 -0
- semantic_code_review/backends/base.py +104 -0
- semantic_code_review/backends/claude_cli.py +421 -0
- semantic_code_review/backends/google_sdk.py +73 -0
- semantic_code_review/backends/openai_compat.py +42 -0
- semantic_code_review/cache/__init__.py +3 -0
- semantic_code_review/cache/store.py +87 -0
- semantic_code_review/cli/__init__.py +70 -0
- semantic_code_review/cli/_shared.py +157 -0
- semantic_code_review/cli/augment.py +74 -0
- semantic_code_review/cli/config_cmd.py +266 -0
- semantic_code_review/cli/fetch.py +33 -0
- semantic_code_review/cli/lint.py +27 -0
- semantic_code_review/cli/pr.py +98 -0
- semantic_code_review/cli/review.py +104 -0
- semantic_code_review/cli/runs_cmd.py +17 -0
- semantic_code_review/cli/show.py +19 -0
- semantic_code_review/cli/strip.py +20 -0
- semantic_code_review/config.py +563 -0
- semantic_code_review/config_template.py +154 -0
- semantic_code_review/fetch/__init__.py +61 -0
- semantic_code_review/fetch/anchor.py +244 -0
- semantic_code_review/fetch/github.py +229 -0
- semantic_code_review/fetch/github_comments.py +404 -0
- semantic_code_review/fetch/local.py +443 -0
- semantic_code_review/fetch/run_source.py +70 -0
- semantic_code_review/format/__init__.py +3 -0
- semantic_code_review/format/emit.py +163 -0
- semantic_code_review/format/lint.py +74 -0
- semantic_code_review/format/parse.py +574 -0
- semantic_code_review/format/sidecar.py +19 -0
- semantic_code_review/format/strip.py +20 -0
- semantic_code_review/git_ops.py +252 -0
- semantic_code_review/paths.py +61 -0
- semantic_code_review/review/__init__.py +3 -0
- semantic_code_review/review/comments.py +176 -0
- semantic_code_review/review/github.py +284 -0
- semantic_code_review/review/github_graphql.py +433 -0
- semantic_code_review/review/pr_flow.py +299 -0
- semantic_code_review/review/runner.py +407 -0
- semantic_code_review/review/server.py +1043 -0
- semantic_code_review/structural/__init__.py +41 -0
- semantic_code_review/structural/diff.py +102 -0
- semantic_code_review/structural/parse.py +330 -0
- semantic_code_review/structural/symbols.py +43 -0
- semantic_code_review/viewer/__init__.py +3 -0
- semantic_code_review/viewer/assets/annotations.ts +547 -0
- semantic_code_review/viewer/assets/boot.ts +273 -0
- semantic_code_review/viewer/assets/comment_store.ts +121 -0
- semantic_code_review/viewer/assets/comments.ts +624 -0
- semantic_code_review/viewer/assets/console.ts +426 -0
- semantic_code_review/viewer/assets/console_render.ts +213 -0
- semantic_code_review/viewer/assets/console_selection.ts +102 -0
- semantic_code_review/viewer/assets/data_store.ts +156 -0
- semantic_code_review/viewer/assets/file_rows.ts +49 -0
- semantic_code_review/viewer/assets/folds.ts +560 -0
- semantic_code_review/viewer/assets/index.html +62 -0
- semantic_code_review/viewer/assets/post_modal.ts +383 -0
- semantic_code_review/viewer/assets/progress.ts +138 -0
- semantic_code_review/viewer/assets/render.ts +937 -0
- semantic_code_review/viewer/assets/sidebar.ts +429 -0
- semantic_code_review/viewer/assets/sse.ts +78 -0
- semantic_code_review/viewer/assets/text_highlight.ts +215 -0
- semantic_code_review/viewer/assets/types.d.ts +388 -0
- semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
- semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
- semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
- semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
- semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
- semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
- semantic_code_review/viewer/assets/viewer.css +1202 -0
- semantic_code_review/viewer/assets/viewer.js +10588 -0
- semantic_code_review/viewer/build_json.py +546 -0
- semantic_code_review/viewer/hunk_layout.py +471 -0
- semantic_code_review-0.24.2.dist-info/METADATA +348 -0
- semantic_code_review-0.24.2.dist-info/RECORD +100 -0
- semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
- semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
- semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|