Files
cex/frontend/explanations.ts
2026-09-07 15:16:52 -04:00

242 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Channel, invoke } from '@tauri-apps/api/core';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
type Audience = { id: string; name: string; instructions: string };
type Settings = { base_url: string; model: string; api_key_env: string; context_bytes: number; max_output_tokens: number; token_parameter: string; stream: boolean; selected_audience: string; audiences: Audience[] };
type Source = { id: string; role: string; path: string; start_line: number; end_line: number; enclosing: string; code: string; truncated: boolean };
type Callee = { is_concept: boolean; is_standard: boolean; id: string; location: { path: string; label: string; range: { start: { line: number; character: number } } } };
type Prepared = { concept_dependencies: boolean; include_standard: boolean; available_callees: Callee[]; selected_callees: string[]; recursion_depth: number; prompt_budget: number; id: number; endpoint: string; model: string; audience: string; prompt_bytes: number; messages: { role: string; content: string }[]; bundle: { selected_path: string; sources: Source[]; usages: unknown[]; references_found: number; callers_found: number; callees_found: number; omissions: string[]; diagnostics: string[] } };
type Options = { workspace: () => { root: string } | null; path: () => string; epoch: () => number; ready: () => Promise<unknown>; open: (path: string, hit: { path: string; line: number; column: number; end_column: number }, semantic?: boolean) => Promise<void> };
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
export function installExplanations(editor: monaco.editor.IStandaloneCodeEditor, options: Options) {
const pane = document.createElement('section'); pane.id = 'explanation-pane'; pane.hidden = true;
pane.innerHTML = `<div class="explanation-heading"><strong>Explain code</strong><button id="model-settings">Model settings</button><button id="close-explanation" aria-label="Close explanation panel">×</button></div>
<div class="explanation-content"><p id="explain-target" class="hint">Select C/C++ code, then prepare context.</p>
<label for="explain-question">Question (optional)</label><textarea id="explain-question" rows="2" placeholder="What does this code do, and how do its callers use it?"></textarea>
<div class="explanation-actions"><button id="prepare-context">Prepare context</button><button id="send-explanation" class="primary" disabled>Send explanation</button><button id="cancel-explanation" hidden>Cancel</button></div>
<fieldset id="callee-options" hidden><legend id="dependency-heading">Called-function bodies</legend><label id="include-standard-label" class="reset-label" hidden><input id="include-standard" type="checkbox">Include std namespace concepts (including nested dependencies)</label><label for="callee-depth">Recursion depth</label><select id="callee-depth"><option value="0">0 — No additional bodies</option><option value="1">1 — Selected direct callees</option><option value="2">2 — Selected callees and their callees</option></select><div class="explanation-actions"><button id="callees-all">Select all</button><button id="callees-none">Select none</button></div><div id="callee-list"></div><button id="apply-callees">Apply body selection</button><p class="hint">Depth is capped at 2. Shared dependencies are deduplicated. Definitions remain subject to the prompt and snapshot limits.</p></fieldset>
<p id="context-size-warning" class="hint" role="status"></p>
<p id="explain-status" class="hint" role="status">Preparation stays local. Sending uses the endpoint you configure.</p>
<p id="explain-destination" class="hint"></p><details id="context-details"><summary>Inspect context</summary><div id="context-summary"></div><div id="context-sources"></div><details><summary>Exact request messages</summary><pre id="context-messages"></pre></details></details>
<p id="citation-status" class="hint" role="status"></p><article id="explanation-answer" aria-label="Model explanation"></article></div>`;
document.querySelector('main')!.append(pane);
const dialog = document.createElement('dialog'); dialog.id = 'llm-settings';
dialog.innerHTML = `<form id="llm-form"><h2>Model settings</h2><p class="hint">Saved for this project. Use an OpenAI-compatible Chat Completions endpoint.</p>
<div class="model-presets"><button id="preset-local" type="button">Local endpoint</button><button id="preset-openai" type="button">OpenAI endpoint</button></div>
<label for="llm-url">API base URL</label><input id="llm-url" required placeholder="http://localhost:1234/v1">
<label for="llm-model">Model identifier</label><input id="llm-model" placeholder="Model name supplied by your server">
<label for="llm-key-env">API key environment variable (optional)</label><input id="llm-key-env" placeholder="OPENAI_API_KEY" autocomplete="off">
<label for="llm-key">API key (current session only)</label><input id="llm-key" type="password" autocomplete="off" placeholder="Leave blank to retain the current session key"><label class="reset-label"><input id="clear-llm-key" type="checkbox">Clear session key</label><p id="key-state" class="hint"></p>
<div class="model-grid"><div><label for="llm-budget">Prompt budget (KiB)</label><input id="llm-budget" type="number" min="16" max="256" required></div><div><label for="llm-output">Output token limit</label><input id="llm-output" type="number" min="128" max="32768" required></div></div>
<label for="llm-token-parameter">Output limit compatibility</label><select id="llm-token-parameter"><option value="max_completion_tokens">max_completion_tokens (OpenAI)</option><option value="max_tokens">max_tokens (older compatible servers)</option><option value="omit">Let the server choose</option></select><label class="reset-label"><input id="llm-stream" type="checkbox">Stream the explanation as it is generated</label>
<label for="llm-audience">Audience profile</label><select id="llm-audience"></select><label for="llm-instructions">Audience instructions (editable for each profile)</label><textarea id="llm-instructions" rows="4" required></textarea>
<p id="llm-error" class="error" role="alert"></p><div class="dialog-actions"><button id="llm-cancel" type="button">Cancel</button><button id="llm-save" type="submit" class="primary">Save settings</button></div></form>`;
document.body.append(dialog);
let prepared: Prepared | null = null, text = '', generation = 0, busy = false;
let questionRevision = 0, questionPending = false, bodiesDirty = false;
let questionTimer: ReturnType<typeof setTimeout> | undefined;
let settings: Settings | null = null, profile = '', renderTimer: ReturnType<typeof setTimeout> | undefined;
function show() { pane.dispatchEvent(new Event('cex-show-chat')); pane.hidden = false; document.querySelector('main')!.classList.add('explaining'); editor.layout(); }
function setBusy(value: boolean) {
busy = value; $<HTMLButtonElement>('prepare-context').disabled = value;
$<HTMLButtonElement>('send-explanation').disabled = value || !prepared || questionPending || bodiesDirty;
$<HTMLFieldSetElement>('callee-options').disabled = value;
$<HTMLButtonElement>('apply-callees').disabled = value || questionPending;
$('cancel-explanation').hidden = !value; $<HTMLButtonElement>('model-settings').disabled = value;
$<HTMLTextAreaElement>('explain-question').disabled = value;
}
function clearContext() { ++questionRevision; clearTimeout(questionTimer); questionPending = false; bodiesDirty = false; $('callee-options').hidden = true; $('context-size-warning').textContent = ''; prepared = null; $<HTMLButtonElement>('send-explanation').disabled = true; $('context-summary').replaceChildren(); $('context-sources').replaceChildren(); $('context-messages').textContent = ''; $('explain-destination').textContent = ''; }
async function citation(source: Source) {
await options.open(source.path, { path: source.path, line: source.start_line, column: 1, end_column: 1 }, true);
const current = editor.getModel()?.getLinesContent().slice(source.start_line - 1, source.end_line).join('\n');
$('citation-status').textContent = current === source.code ? `Opened ${source.path}:${source.start_line}.` : 'This file differs from the reviewed snapshot or could not be opened. Inspect context to see the exact evidence sent.';
}
function renderAnswer() {
const html = marked.parse(text, { async: false });
$('explanation-answer').innerHTML = DOMPurify.sanitize(html, { FORBID_TAGS: ['img', 'svg', 'math', 'iframe', 'object', 'form', 'input', 'button', 'video', 'audio'], FORBID_ATTR: ['style', 'src', 'srcset'] });
// Model-supplied URLs are text only. Only source IDs from our reviewed bundle
// become actionable links; citations cannot grant arbitrary file access.
$('explanation-answer').querySelectorAll('a').forEach(a => a.replaceWith(document.createTextNode(a.textContent ?? '')));
const walker = document.createTreeWalker($('explanation-answer'), NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
while (walker.nextNode()) { const node = walker.currentNode as Text; if (!node.parentElement?.closest('pre,code')) nodes.push(node); }
for (const node of nodes) {
const matches = [...node.data.matchAll(/\[S[1-9]\d*\]/g)]; if (!matches.length) continue;
const fragment = document.createDocumentFragment(); let offset = 0;
for (const match of matches) {
fragment.append(node.data.slice(offset, match.index));
const id = match[0].slice(1, -1), source = prepared?.bundle.sources.find(s => s.id === id);
if (source) { const button = document.createElement('button'); button.className = 'source-citation'; button.textContent = match[0]; button.title = `${source.path}:${source.start_line}-${source.end_line}`; button.onclick = () => void citation(source); fragment.append(button); }
else { const unknown = document.createElement('span'); unknown.textContent = `${match[0]} (unknown source)`; unknown.className = 'error'; fragment.append(unknown); }
offset = match.index! + match[0].length;
}
fragment.append(node.data.slice(offset)); node.replaceWith(fragment);
}
}
function renderContext(value: Prepared, controls = true) {
const fraction = value.prompt_bytes / value.prompt_budget;
$('context-size-warning').textContent = `${(value.prompt_bytes / 1024).toFixed(1)} KiB / ${(value.prompt_budget / 1024).toFixed(0)} KiB prompt budget. ${fraction >= 0.75 || value.prompt_bytes >= 32768 ? 'Large context: sending this much code can increase latency and cost and leave less model capacity for the answer.' : 'More included bodies increase request size and generation cost.'}${value.bundle.sources.some(s => s.truncated) ? ' Some bodies are truncated; inspect the snapshots.' : ''}`;
if (controls) {
bodiesDirty = false;
$('callee-options').hidden = false;
$('apply-callees').textContent = value.concept_dependencies ? 'Apply concept selection' : 'Apply body selection';
$('dependency-heading').textContent = value.concept_dependencies ? 'Concept dependencies' : 'Called-function bodies';
$('include-standard-label').hidden = !value.concept_dependencies;
$<HTMLInputElement>('include-standard').checked = value.include_standard ?? false;
const depthOptions = $<HTMLSelectElement>('callee-depth').options;
depthOptions[0].text = '0 — No additional definitions';
depthOptions[1].text = value.concept_dependencies ? '1 — Selected concepts' : '1 — Selected direct callees';
depthOptions[2].text = value.concept_dependencies ? '2 — Selected concepts and their dependencies' : '2 — Selected callees and their callees';
$<HTMLSelectElement>('callee-depth').value = String(value.recursion_depth);
$('callee-list').replaceChildren();
for (const callee of value.available_callees) {
const label = document.createElement('label'), input = document.createElement('input');
input.type = 'checkbox'; input.value = callee.id; input.dataset.standard = String(callee.is_standard ?? false); input.disabled = callee.is_standard && !value.include_standard; input.checked = !input.disabled && value.selected_callees.includes(callee.id);
input.onchange = bodiesChanged;
label.append(input, ` ${callee.location.label || 'Dependency'}${callee.is_standard ? ' [std]' : ''}${callee.location.path}:${callee.location.range.start.line + 1}`);
$('callee-list').append(label);
}
if (!value.available_callees.length) $('callee-list').textContent = value.concept_dependencies ? 'No referenced concepts found in this constraint.' : 'No direct callees found for this selection.';
}
$('explain-target').textContent = value.bundle.selected_path;
$('explain-destination').textContent = `Send to ${value.endpoint} · model: ${value.model || '(not configured)'} · audience: ${value.audience}`;
const summary = document.createElement('p'); summary.className = 'hint';
summary.textContent = `${value.bundle.sources.length} snapshots · ${value.bundle.references_found} references · ${value.bundle.callers_found} callers · ${value.concept_dependencies ? `${value.available_callees.length} concept dependencies` : `${value.bundle.callees_found} callees`} · ${(value.prompt_bytes / 1024).toFixed(1)} KiB of prompt messages (bytes, not tokens).`;
$('context-summary').replaceChildren(summary);
for (const message of [...value.bundle.omissions, ...value.bundle.diagnostics]) { const p = document.createElement('p'); p.className = 'hint'; p.textContent = message; $('context-summary').append(p); }
$('context-sources').replaceChildren();
for (const source of value.bundle.sources) {
const details = document.createElement('details'), title = document.createElement('summary'), pre = document.createElement('pre');
title.textContent = `[${source.id}] ${source.role}: ${source.path}:${source.start_line}-${source.end_line}${source.enclosing ? ` · ${source.enclosing}` : ''}${source.truncated ? ' (truncated)' : ''}`;
pre.textContent = source.code.split('\n').map((line, i) => `${source.start_line + i}: ${line}`).join('\n');
details.append(title, pre); $('context-sources').append(details);
}
$('context-messages').textContent = JSON.stringify(value.messages, null, 2);
}
async function prepareContext() {
const workspace = options.workspace(), path = options.path();
if (!workspace || !path || editor.getModel()?.getLanguageId() !== 'cpp') { $('explain-status').textContent = 'Open a C/C++ file and select a symbol first.'; return; }
show(); const operation = ++generation, epoch = options.epoch();
const selected = editor.getSelection();
const position = selected && !selected.isEmpty() ? selected.getStartPosition() : editor.getPosition()!;
const selection = selected && !selected.isEmpty() ? { start: { line: selected.startLineNumber - 1, character: selected.startColumn - 1 }, end: { line: selected.endLineNumber - 1, character: selected.endColumn - 1 } } : null;
clearContext(); text = ''; $('explanation-answer').replaceChildren(); $('citation-status').textContent = '';
$('explain-status').textContent = 'Gathering compiler-backed context locally…'; setBusy(true);
try {
await options.ready();
if (operation !== generation || epoch !== options.epoch()) return;
const value = await invoke<Prepared>('prepare_explanation', { root: workspace.root, path, position: { line: position.lineNumber - 1, character: position.column - 1 }, selection, question: $<HTMLTextAreaElement>('explain-question').value });
if (operation !== generation || epoch !== options.epoch()) return;
prepared = value; renderContext(value); $<HTMLDetailsElement>('context-details').open = true;
$('explain-status').textContent = 'Context ready. Review the evidence and destination, then send.';
} catch (error) { if (operation === generation) $('explain-status').textContent = String(error); }
finally { if (operation === generation) setBusy(false); }
}
function bodiesChanged() {
bodiesDirty = true;
if ($<HTMLSelectElement>('callee-depth').value === '0' && $('callee-list').querySelector('input:checked')) $<HTMLSelectElement>('callee-depth').value = '1';
setBusy(busy); $('explain-status').textContent = 'Dependency selection changed. Apply it to update the context and size preview.';
}
$('callee-depth').onchange = () => { bodiesDirty = true; setBusy(busy); $('explain-status').textContent = 'Apply body selection to update the preview.'; };
$('include-standard').onchange = () => {
const include = $<HTMLInputElement>('include-standard').checked;
$('callee-list').querySelectorAll<HTMLInputElement>('input[data-standard="true"]').forEach(input => { input.disabled = !include; if (!include) input.checked = false; });
bodiesChanged();
};
$('callees-all').onclick = () => { $('callee-list').querySelectorAll<HTMLInputElement>('input').forEach(input => input.checked = !input.disabled); bodiesChanged(); };
$('callees-none').onclick = () => { $('callee-list').querySelectorAll<HTMLInputElement>('input').forEach(input => input.checked = false); bodiesChanged(); };
$('apply-callees').onclick = async () => {
const workspace = options.workspace(); if (!workspace || !prepared || busy || questionPending) return;
const operation = ++generation, epoch = options.epoch(); setBusy(true);
$('explain-status').textContent = 'Collecting selected dependency definitions locally…';
try {
const value = await invoke<Prepared>('expand_explanation', { root: workspace.root, id: prepared.id, selected: [...$('callee-list').querySelectorAll<HTMLInputElement>('input:checked')].map(input => input.value), depth: Number($<HTMLSelectElement>('callee-depth').value), includeStandard: $<HTMLInputElement>('include-standard').checked, question: $<HTMLTextAreaElement>('explain-question').value });
if (operation !== generation || epoch !== options.epoch()) return;
prepared = value; text = ''; renderAnswer(); renderContext(value);
$('explain-status').textContent = 'Body selection applied. Review the size and any omissions before sending.';
} catch (error) { if (operation === generation) $('explain-status').textContent = String(error); }
finally { if (operation === generation) setBusy(false); }
};
$('explain-question').oninput = () => {
clearTimeout(questionTimer); const revision = ++questionRevision;
if (!prepared) return;
questionPending = true; setBusy(busy);
$('explain-status').textContent = 'Updating prompt using the existing source snapshots…';
questionTimer = setTimeout(async () => {
const workspace = options.workspace(), context = prepared; if (!workspace || !context) return;
try {
const value = await invoke<Prepared>('update_explanation_question', { root: workspace.root, id: context.id, question: $<HTMLTextAreaElement>('explain-question').value });
if (revision !== questionRevision) return;
prepared = value; renderContext(value, false); questionPending = false; setBusy(busy);
$('explain-status').textContent = 'Prompt updated. Existing source snapshots are ready to send.';
} catch (error) { if (revision === questionRevision) $('explain-status').textContent = String(error); }
}, 200);
};
dialog.addEventListener('cancel', () => { $<HTMLInputElement>('llm-key').value = ''; });
$('prepare-context').onclick = () => void prepareContext();
$('nav-explain').onclick = () => { show(); if (!busy) void prepareContext(); };
editor.addAction({ id: 'cex.explain', label: 'Explain Selected Code…', contextMenuGroupId: 'navigation', contextMenuOrder: 2, run: () => { show(); if (!busy) return prepareContext(); } });
$('send-explanation').onclick = async () => {
const workspace = options.workspace(); if (!workspace || !prepared || busy || questionPending || bodiesDirty) return;
const operation = ++generation, epoch = options.epoch(), id = prepared.id;
setBusy(true); text = ''; $('explanation-answer').replaceChildren();
$('explain-status').textContent = `Requesting explanation from ${prepared.endpoint}`;
const onDelta = new Channel<string>();
onDelta.onmessage = delta => {
if (operation !== generation || epoch !== options.epoch()) return;
text += delta;
if (renderTimer === undefined) renderTimer = setTimeout(() => { renderTimer = undefined; renderAnswer(); }, 80);
};
try {
const result = await invoke<{ text: string; finish_reason: string }>('send_explanation', { root: workspace.root, id, onDelta });
if (operation !== generation || epoch !== options.epoch()) return;
text = result.text; renderAnswer();
$('explain-status').textContent = result.finish_reason === 'length' ? 'Output limit reached; this explanation may be incomplete.' : result.finish_reason === 'content_filter' ? 'The provider filtered this response; it may be incomplete.' : 'Explanation complete. Source citations open the reviewed locations.';
} catch (error) { if (operation === generation) { $('explain-status').textContent = `${String(error)}${text ? ' Text shown is partial.' : ''}`; renderAnswer(); } }
finally { if (operation === generation) setBusy(false); }
};
$('cancel-explanation').onclick = () => {
++generation; const workspace = options.workspace();
if (workspace) void invoke('cancel_explanation', { root: workspace.root }).catch(error => { $('explain-status').textContent = String(error); });
setBusy(false); renderAnswer(); $('explain-status').textContent = `Cancelled.${text ? ' Text shown is partial.' : ''}`;
};
$('close-explanation').onclick = () => { pane.hidden = true; document.querySelector('main')!.classList.remove('explaining'); editor.layout(); };
function audienceInstructions() { if (settings) { const a = settings.audiences.find(a => a.id === profile); if (a) a.instructions = $<HTMLTextAreaElement>('llm-instructions').value; } }
$('llm-audience').onchange = () => { audienceInstructions(); profile = $<HTMLSelectElement>('llm-audience').value; $<HTMLTextAreaElement>('llm-instructions').value = settings?.audiences.find(a => a.id === profile)?.instructions ?? ''; };
$('model-settings').onclick = async () => {
const workspace = options.workspace(); if (!workspace) { $('explain-status').textContent = 'Open a project first.'; return; }
const epoch = options.epoch();
try {
const result = await invoke<{ settings: Settings; has_session_key: boolean }>('get_llm_settings', { root: workspace.root }); if (epoch !== options.epoch()) return;
settings = result.settings;
$<HTMLInputElement>('llm-url').value = settings.base_url; $<HTMLInputElement>('llm-model').value = settings.model;
$<HTMLInputElement>('llm-key-env').value = settings.api_key_env; $<HTMLInputElement>('llm-key').value = ''; $<HTMLInputElement>('clear-llm-key').checked = false;
$('key-state').textContent = result.has_session_key ? 'A key is set for this session and endpoint.' : 'No session key is set. Local endpoints may not require one.';
$<HTMLInputElement>('llm-budget').value = String(settings.context_bytes / 1024); $<HTMLInputElement>('llm-output').value = String(settings.max_output_tokens);
$<HTMLSelectElement>('llm-token-parameter').value = settings.token_parameter; $<HTMLInputElement>('llm-stream').checked = settings.stream;
$('llm-audience').replaceChildren(...settings.audiences.map(a => { const option = document.createElement('option'); option.value = a.id; option.textContent = a.name; return option; }));
profile = settings.selected_audience; $<HTMLSelectElement>('llm-audience').value = profile; $<HTMLTextAreaElement>('llm-instructions').value = settings.audiences.find(a => a.id === profile)!.instructions;
$('llm-error').textContent = ''; dialog.showModal();
} catch (error) { $('explain-status').textContent = String(error); }
};
$('preset-local').onclick = () => { $<HTMLInputElement>('llm-url').value = 'http://localhost:1234/v1'; $<HTMLInputElement>('llm-key-env').value = ''; $<HTMLInputElement>('clear-llm-key').checked = true; };
$('preset-openai').onclick = () => { $<HTMLInputElement>('llm-url').value = 'https://api.openai.com/v1'; $<HTMLInputElement>('llm-key-env').value = 'OPENAI_API_KEY'; $<HTMLInputElement>('clear-llm-key').checked = true; };
$('llm-cancel').onclick = () => { $<HTMLInputElement>('llm-key').value = ''; dialog.close(); };
$('llm-form').onsubmit = async event => {
event.preventDefault(); const workspace = options.workspace(); if (!workspace || !settings) return;
audienceInstructions();
settings = { ...settings, base_url: $<HTMLInputElement>('llm-url').value.trim(), model: $<HTMLInputElement>('llm-model').value.trim(), api_key_env: $<HTMLInputElement>('llm-key-env').value.trim(), context_bytes: Number($<HTMLInputElement>('llm-budget').value) * 1024, max_output_tokens: Number($<HTMLInputElement>('llm-output').value), token_parameter: $<HTMLSelectElement>('llm-token-parameter').value, stream: $<HTMLInputElement>('llm-stream').checked, selected_audience: profile };
const key = $<HTMLInputElement>('llm-key').value; const apiKey = key || ($<HTMLInputElement>('clear-llm-key').checked ? '' : null);
$<HTMLInputElement>('llm-key').value = ''; $<HTMLButtonElement>('llm-save').disabled = true;
try { await invoke('save_llm_settings', { root: workspace.root, settings, apiKey }); clearContext(); text = ''; renderAnswer(); dialog.close(); $('explain-status').textContent = 'Settings saved. Prepare context using the selected audience before sending.'; }
catch (error) { $('llm-error').textContent = String(error); }
finally { $<HTMLButtonElement>('llm-save').disabled = false; }
};
return {
reset() { ++generation; clearTimeout(renderTimer); renderTimer = undefined; clearContext(); text = ''; $('explanation-answer').replaceChildren(); $('citation-status').textContent = ''; $('explain-target').textContent = 'Select C/C++ code, then prepare context.'; $('explain-status').textContent = 'Preparation stays local. Sending uses the endpoint you configure.'; setBusy(false); dialog.close(); $<HTMLInputElement>('llm-key').value = ''; },
fileOpened() { $<HTMLButtonElement>('nav-explain').disabled = editor.getModel()?.getLanguageId() !== 'cpp'; },
};
}