This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/ui-format.js
2026-03-21 20:16:19 +00:00

629 lines
26 KiB
JavaScript
Raw 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.
// ==========================================
// Chat Switchboard UI Formatting & Helpers
// ==========================================
// Markdown rendering, code blocks, preview panel, time formatting.
// esc() is in ui-primitives.js (loaded globally in base.html).
//
// Exports: formatMessage, clearPreview, runExtensionPostRender,
// _livePreviewUpdate, _relativeTime, _renderToolCallsHTML,
// _registerPreviewPanel,
// toggleCodeCollapse, toggleHTMLPreview, downloadCode,
// popOutExtBlock (onclick handlers)
// ==========================================
// ── Message Formatting ──────────────────────
function formatMessage(content) {
if (!content) return '';
const thinkingBlocks = [];
let text = content;
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
const id = 'think-' + Math.random().toString(36).slice(2, 9);
thinkingBlocks.push({ id, content: inner.trim() });
// Newlines ensure the placeholder is its own markdown block so adjacent
// code fences (e.g. "</think>```html") start on a fresh line for marked.
return `\n\nTHINK_PLACEHOLDER_${id}\n\n`;
});
let html;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
html = _formatMarked(text);
} else {
html = _formatBasic(text);
}
for (const b of thinkingBlocks) {
const inner = esc(b.content).replace(/\n/g, '<br>');
const openAttr = App.settings.showThinking ? ' open' : '';
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
}
// @mention highlighting (v0.23.0): style @Name references as pills
html = _highlightMentions(html);
return html;
}
/**
* Highlight @mentions in rendered HTML as styled pills.
* Matches @Name patterns against the channel model roster.
* Only highlights inside text nodes (not inside code/pre/a tags).
*/
function _highlightMentions(html) {
if (typeof ChannelModels === 'undefined') return html;
const roster = ChannelModels.getRoster();
// Build patterns from roster handles and display names (AI mentions)
const aiPatterns = [];
const seen = new Set();
if (roster && roster.length >= 2) {
for (const m of roster) {
if (m.handle) {
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped)) {
aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped);
}
}
if (m.display_name) {
const hyphenated = m.display_name.replace(/\s+/g, '-');
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped.toLowerCase())) {
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped.toLowerCase());
}
}
}
}
// @all is special
aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
// v0.23.2: Build user mention patterns
const userPatterns = [];
for (const u of (App.users || [])) {
const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped.toLowerCase())) {
userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped.toLowerCase());
}
}
// Don't highlight inside <code>, <pre>, <a> tags
const parts = html.split(/(<[^>]+>)/);
let inCode = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('<')) {
const tag = part.toLowerCase();
if (tag.startsWith('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
continue;
}
if (inCode) continue;
let text = part;
// AI mentions first (persona/model)
for (const re of aiPatterns) {
text = text.replace(re, '<span class="mention-pill">$1</span>');
}
// User mentions (different color)
for (const re of userPatterns) {
text = text.replace(re, '<span class="mention-pill mention-user">$1</span>');
}
parts[i] = text;
}
return parts.join('');
}
function _formatMarked(text) {
// ── Unwrap outer ```markdown wrappers ────────────────────────
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
// semantically "this content IS markdown". Standard markdown doesn't support
// nested fences with the same delimiter, so an inner ```mermaid ... ```
// prematurely closes the outer block, splitting the content into broken
// fragments. Fix: if the outer fence is ```markdown/```md and contains
// nested fences, strip the wrapper and render contents as markdown directly.
text = _unwrapMarkdownFence(text);
// Close any unclosed code fences to prevent raw HTML injection during
// streaming (closing ``` hasn't arrived yet) or when the model forgets
// to close a fence. An odd number of ``` markers means one is unclosed.
const fences = text.match(/^```/gm);
if (fences && fences.length % 2 !== 0) {
text += '\n```';
}
const rendered = marked.parse(text, { breaks: true, gfm: true });
let html = DOMPurify.sanitize(rendered, {
// Strict allowlist: only elements that marked.js actually produces.
// This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.)
// from rendering live even if a code fence fails to parse.
ALLOWED_TAGS: [
// Block elements
'p', 'br', 'hr', 'pre', 'code', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Lists
'ul', 'ol', 'li',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
// Inline formatting
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup',
'a', 'img', 'span', 'div',
// Think blocks (injected post-sanitize, but placeholders may be in <p>)
'details', 'summary',
],
ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title',
'colspan', 'rowspan', 'align'],
});
// Process code blocks: add copy button, collapse toggle, HTML preview
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
const langMatch = attrs.match(/class="language-(\w+)"/);
const lang = langMatch ? langMatch[1].toLowerCase() : '';
// Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
// Extensions mark blocks with data-ext-render for post-render DOM processing.
if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
try {
const fakeContainer = document.createElement('div');
const decoded = _decodeHTML(code);
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
if (handled && fakeContainer.innerHTML) {
const popBtn = `<button class="ext-popout-btn" data-action="popOutExtBlock" data-args='${JSON.stringify([codeId])}'" title="Open in side panel">⧉</button>`;
return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`;
}
if (handled && !fakeContainer.innerHTML) {
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
}
} catch (e) {
console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
}
}
// Count lines for collapse decision
const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
// Build toolbar buttons
const collapseBtn = lineCount > 5
? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([codeId])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
: '';
let toolbar = collapseBtn;
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([codeId])}'">Copy</button>`;
toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([codeId, lang])}'">Download</button>`;
// HTML preview button (only for HTML-like content)
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([codeId])}'">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
});
return html;
}
function _formatBasic(text) {
let html = esc(text);
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const id = 'code-' + Math.random().toString(36).slice(2, 9);
const lineCount = (code.match(/\n/g) || []).length + 1;
const COLLAPSE_THRESHOLD = 15;
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
const collapseBtn = lineCount > 5
? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([id])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
: '';
let toolbar = collapseBtn;
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([id])}'">Copy</button>`;
toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([id, lang])}'">Download</button>`;
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([id])}'">Preview</button>`;
}
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
});
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\n/g, '<br>');
return html;
}
// ── Markdown Fence Unwrapper ──────────────────
// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
// a no-op ("this markdown IS markdown") but breaks parsing when the content
// contains nested code fences — the inner closing ``` prematurely terminates
// the outer block, splitting the response into fragments.
//
// Strategy: if the text is wrapped in ```markdown/```md and the body contains
// at least one nested ``` fence, strip the outer wrapper. If there are no
// nested fences, leave it alone (the user/LLM may genuinely want to show
// markdown source in a code block).
function _unwrapMarkdownFence(text) {
// The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
// We need to find the ```markdown fence after any placeholders/whitespace.
const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
if (!openMatch) return text;
const prefix = openMatch[1]; // placeholders + whitespace before the fence
const fence = openMatch[2]; // the ```markdown\n itself
const afterOpen = prefix.length + fence.length;
// Find the LAST bare ``` on its own line — this is the outer closing fence.
// There may be trailing text (LLM explanations) after it.
let closeIdx = -1;
const searchRegex = /\n```[ \t]*(?:\n|$)/g;
let m;
while ((m = searchRegex.exec(text)) !== null) {
closeIdx = m.index;
}
if (closeIdx < afterOpen) return text;
// Extract inner content and any trailing text after the close
const inner = text.slice(afterOpen, closeIdx);
const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
// Only unwrap if the inner content contains nested fences —
// otherwise this might be intentional "show me the markdown source"
if (/^```/m.test(inner)) {
return prefix + inner + trailing;
}
return text;
}
// Decode HTML entities in code block content (for extension renderers that need raw text)
function _decodeHTML(html) {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
// Heuristic: does this code block look like HTML?
function _looksLikeHTML(code) {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
}
// ── Code Block Actions ──────────────────────
function toggleCodeCollapse(codeId) {
const pre = document.getElementById('block-' + codeId);
if (!pre) return;
const isCollapsed = pre.classList.toggle('code-collapsed');
const btn = pre.querySelector('.code-collapse-btn');
if (btn) {
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
btn.title = isCollapsed ? 'Expand' : 'Collapse';
}
}
function toggleHTMLPreview(codeId) {
const codeEl = document.getElementById(codeId);
if (!codeEl) return;
const rawHTML = codeEl.textContent;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
frame.srcdoc = rawHTML;
frame.style.display = '';
if (empty) empty.style.display = 'none';
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
function clearPreview() {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
if (empty) empty.style.display = '';
PanelRegistry.refreshActions();
}
function downloadCode(codeId, lang) {
const el = document.getElementById(codeId);
if (!el) return;
const extMap = {
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
golang: 'go', go: 'go', rust: 'rs', java: 'java', cpp: 'cpp',
c: 'c', csharp: 'cs', css: 'css', html: 'html', htm: 'html',
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', sql: 'sql',
bash: 'sh', shell: 'sh', sh: 'sh', markdown: 'md', md: 'md',
toml: 'toml', dockerfile: 'Dockerfile', makefile: 'Makefile',
perl: 'pl', lua: 'lua', swift: 'swift', kotlin: 'kt', php: 'php',
};
const ext = extMap[lang?.toLowerCase()] || lang || 'txt';
const filename = `code.${ext}`;
const blob = new Blob([el.textContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
UI.toast('Downloaded ' + filename, 'success');
}
// ── Preview Panel Registration ──────────────
function _registerPreviewPanel() {
const el = document.getElementById('sidePanelPreview');
if (!el) return;
PanelRegistry.register('preview', {
element: el,
label: 'Preview',
actions: [
{
id: 'sidePanelClearBtn',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
title: 'Clear preview',
onClickName: 'clearPreview()',
visible: _hasPreviewContent,
},
],
saveState() {
const frame = document.getElementById('previewFrame');
return {
srcdoc: frame?.srcdoc || '',
hasContent: frame?.style.display !== 'none',
};
},
restoreState(state) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame && state.srcdoc) {
frame.srcdoc = state.srcdoc;
frame.style.display = state.hasContent ? '' : 'none';
if (empty) empty.style.display = state.hasContent ? 'none' : '';
}
},
});
}
function _hasPreviewContent() {
const frame = document.getElementById('previewFrame');
return frame && frame.style.display !== 'none' && frame.srcdoc;
}
// ── Pop-out for Extension-Rendered Blocks ───
/**
* Open an extension-rendered block (Mermaid, KaTeX, etc.) in the
* preview panel at full size.
*/
function popOutExtBlock(blockId) {
const block = document.querySelector(`[data-ext-block="${blockId}"]`);
if (!block) return;
// Clone rendered content (skip the pop-out button itself)
const clone = block.cloneNode(true);
const btn = clone.querySelector('.ext-popout-btn');
if (btn) btn.remove();
const lang = block.getAttribute('data-ext-lang') || '';
// Read current theme colors so the iframe respects light/dark mode
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-surface').trim() || '#fff';
const textColor = cs.getPropertyValue('--text').trim() || '#1a1a2e';
// Wrap in a minimal HTML document for the iframe
const html = `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 16px; display: flex; justify-content: center;
align-items: flex-start; min-height: 100vh; background: ${bgColor}; color: ${textColor};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body > * { max-width: 100%; }
svg { max-width: 100%; height: auto; }
</style>
</head><body>${clone.innerHTML}</body></html>`;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
// ── Live Preview During Streaming ───────────
let _livePreviewTimer = null;
/**
* Called on each streaming delta with the accumulated raw markdown
* content. If the preview panel is open, extracts the last completed
* HTML code block and updates the iframe. Debounced at 500ms.
*/
function _livePreviewUpdate(rawContent) {
// Only live-update if preview is the active panel
if (!PanelRegistry.isOpen('preview')) return;
clearTimeout(_livePreviewTimer);
_livePreviewTimer = setTimeout(() => {
const html = _extractLastHTMLBlock(rawContent);
if (!html) return;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
PanelRegistry.refreshActions();
}, 500);
}
/**
* Extract the last completed fenced HTML code block from raw markdown.
* Returns the raw HTML content or null if none found.
*
* "Completed" means both opening ``` and closing ``` are present.
*/
function _extractLastHTMLBlock(text) {
if (!text) return null;
// Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML)
const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi;
let lastMatch = null;
let m;
while ((m = pattern.exec(text)) !== null) {
const blockContent = m[1];
const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || '';
if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) {
lastMatch = blockContent;
}
}
return lastMatch;
}
// ── Helpers ─────────────────────────────────
// Render tool calls from stored message data (history view)
function _renderToolCallsHTML(toolCalls) {
if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return '';
const items = toolCalls.map(tc => {
const name = tc.function?.name || tc.name || 'unknown';
const args = tc.function?.arguments || tc.arguments || tc.input || '';
const result = tc.result || '';
const isError = tc.is_error || false;
const tcId = tc.id || '';
const statusCls = isError ? 'tool-error' : 'tool-done';
const statusText = isError ? 'error' : 'done';
// Check for note tool — add view link
let noteLink = '';
if (name.startsWith('note_') && result) {
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result;
if (parsed.id) {
noteLink = `<button class="tool-note-link" data-action="_openNoteFromTool" data-args='${JSON.stringify([esc(parsed.id)])}'">📝 View</button>`;
}
} catch (e) { /* not JSON */ }
}
// Build collapsible detail
let detailHTML = '';
if (args || result) {
const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
detailHTML = `<div class="tool-detail">`;
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
detailHTML += `</div>`;
}
return `<details class="tool-call-block">
<summary class="tool-call-summary">
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status ${statusCls}">${statusText}</span>
${noteLink}
</summary>
${detailHTML}
</details>`;
});
return `<div class="msg-tools">${items.join('')}</div>`;
}
function _relativeTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
/**
* Run extension post-renderers on a container after innerHTML is set.
* Safe to call even if extensions aren't loaded — no-ops gracefully.
*/
function runExtensionPostRender(container) {
// v0.28.5: Render pipe subsumes extension post-renderers.
// The pipe includes compat-shimmed renderers from ctx.renderers.register()
// alongside new sw.pipe.render() filters.
if (typeof sw !== 'undefined' && sw.pipe) {
const renderCtx = {
element: container,
message: null, // batch render — no single message context
channel: {
id: typeof App !== 'undefined' ? App.activeId : null,
type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct',
},
};
sw.pipe._runRender(renderCtx);
return;
}
// Fallback: SDK not loaded (shouldn't happen, but defensive)
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
Extensions.runPostRenderers(container);
}
}
// ── Wrappers for compound onclick expressions ──
function _copyCodeBlock(codeId) {
const el = document.getElementById(codeId);
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
}
function _openNoteFromTool(noteId) {
// v0.37.9: old notes.js removed — navigate to notes surface instead
if (window.sw?.notesPane) {
console.log('[ui-format] Note tool link — navigate to /notes');
}
}
// ── Exports ─────────────────────────────────
// Cross-file function calls
sb.register('formatMessage', formatMessage);
sb.register('clearPreview', clearPreview);
sb.register('runExtensionPostRender', runExtensionPostRender);
sb.register('_livePreviewUpdate', _livePreviewUpdate);
sb.register('_relativeTime', _relativeTime);
sb.register('_renderToolCallsHTML', _renderToolCallsHTML);
sb.register('_registerPreviewPanel', _registerPreviewPanel);
// data-action handlers
sb.register('toggleCodeCollapse', toggleCodeCollapse);
sb.register('toggleHTMLPreview', toggleHTMLPreview);
sb.register('downloadCode', downloadCode);
sb.register('popOutExtBlock', popOutExtBlock);
sb.register('_copyCodeBlock', _copyCodeBlock);
sb.register('_openNoteFromTool', _openNoteFromTool);