// IDE Remote: the Claude conversation view. Classic script: window.IdeRemoteViews.claude = {mount(container)}.// The pure part (events -> items) is exported for node tests (claude.test.mjs); it mirrors ide/ui/js/claude.js (event / streamEvent / toolRow).(function (root, factory) { const api = factory(root); if (typeof module === 'object' && module.exports) module.exports = api; else { (root.IdeRemoteViews = root.IdeRemoteViews || {}).claude = { mount: api.mount, pure: api.pure }; if (root.document && root.document.getElementById) api.boot('claude', api.mount, ['vendor/ide-md.js']); }})(typeof window !== 'undefined' ? window : globalThis, function (root) { // Texts: the page's table (config/lang.yaml, 14 languages) through IdeRemote.t; the English given here is the fallback (and what tests see). const L = (key, en, vars) => (root.IdeRemote && root.IdeRemote.t ? root.IdeRemote.t(key, vars, en) : String(en).replace(/\{(\w+)\}/g, (m, k) => (vars && k in vars ? vars[k] : m))); const errText = (e) => (root.IdeRemote && root.IdeRemote.errorText ? root.IdeRemote.errorText(e) : (e && e.message) || String(e)); // ---------------------------------------------------------------- pure part function newState() { return { items: [], live: null, working: false, session: '', conv: '', runId: null, tools: 0, status: '', gen: 0 }; } function toText(content) { if (typeof content === 'string') return content; if (Array.isArray(content)) return content.map((c) => (typeof c === 'string' ? c : c && c.text) || '').join('\n'); return ''; } const base = (p) => String(p || ''); /** One row for a tool call: {label, what} (like toolRow of the IDE's panel). */ function toolLabel(name, input) { input = input || {}; name = String(name || 'tool'); if (name.startsWith('mcp__')) { const parts = name.split('__'); const tool = parts[parts.length - 1]; const what = input.path || input.query || input.command || input.description || ''; return { label: tool.replace(/_/g, ' '), what: base(what).slice(0, 300), mcp: true }; } const file = input.file_path || input.notebook_path || input.path; const table = { Read: [L('tool_read', 'Read'), file], Write: [L('tool_wrote', 'Wrote'), file], Edit: [L('tool_edited', 'Edited'), file], MultiEdit: [L('tool_edited', 'Edited'), file], Bash: ['$', input.command], Glob: [L('tool_looked', 'Looked for'), input.pattern], Grep: [L('tool_searched', 'Searched'), input.pattern], WebFetch: [L('tool_fetched', 'Fetched'), input.url], WebSearch: [L('tool_searched_web', 'Searched the web'), input.query], TodoWrite: [L('tool_planned', 'Planned'), L('tool_steps', '{n} step(s)', { n: (input.todos || []).length })], }; const row = table[name] || [name, input.description || '']; return { label: row[0], what: base(row[1]).slice(0, 300) }; } /** The partial stream (--include-partial-messages): grows st.live = {kind: 'thinking'|'text'|'tool', buf, name}. */ function streamEvent(st, e) { if (!e) return 'none'; if (e.type === 'message_start') { st.live = null; return 'live'; } if (e.type === 'content_block_start') { const t = e.content_block && e.content_block.type; st.live = (t === 'thinking' || t === 'text' || t === 'tool_use') ? { kind: t === 'tool_use' ? 'tool' : t, buf: '', name: (e.content_block && e.content_block.name) || '' } : null; st.status = t === 'thinking' ? 'thinking…' : t === 'text' ? 'writing…' : t === 'tool_use' ? 'preparing an action…' : st.status; return 'live'; } if (e.type === 'content_block_delta' && st.live) { const d = e.delta || {}; st.live.buf += (d.thinking !== undefined ? d.thinking : d.text !== undefined ? d.text : d.partial_json !== undefined ? d.partial_json : '') || ''; return 'live'; } return 'none'; } function add(st, item) { st.items.push(item); return 'items'; } /** * Applies one claude.event payload {id, event}. Returns what changed: 'items' | 'live' | 'none' * (the view paints new items, or only the live node). */ function applyEvent(st, payload) { const event = payload && payload.event; if (!event) return 'none'; if (payload.id !== undefined && payload.id !== null) st.runId = payload.id; if (payload.conv && !st.conv) st.conv = payload.conv; if (event.session_id) st.session = event.session_id; if (event.type !== 'result' && event.type !== 'system') st.working = true; if (event.type === 'stream_event') return streamEvent(st, event.event); if (event.type === 'system' && event.subtype === 'init') { st.status = event.model ? String(event.model) : 'working…'; return 'none'; } if (event.type === 'assistant') { st.live = null; let changed = 'live'; for (const part of (event.message && event.message.content) || []) { if (part.type === 'thinking' && part.thinking && part.thinking.trim()) changed = add(st, { role: 'thinking', text: part.thinking }); else if (part.type === 'text' && part.text && part.text.trim()) changed = add(st, { role: 'assistant', text: part.text }); else if (part.type === 'tool_use') { st.tools++; changed = add(st, { role: 'tool', name: part.name, input: part.input || {} }); } } return changed; } if (event.type === 'user') { let changed = 'none'; for (const part of (event.message && event.message.content) || []) { if (part.type === 'tool_result' && part.is_error) changed = add(st, { role: 'tool-error', text: toText(part.content).slice(0, 600) }); } return changed; } if (event.type === 'result') { st.live = null; const seconds = Math.round((event.duration_ms || 0) / 1000); if (event.is_error && event.result) add(st, { role: 'error', text: String(event.result) }); if (event.permission_denials && event.permission_denials.length) { add(st, { role: 'note', text: L('refused', 'Refused: {tools}. Change what Claude may do on the computer and ask again.', { tools: event.permission_denials.map((d) => d.tool_name).join(', ') }) }); } add(st, { role: 'done', text: L('done_line', '{state} · {seconds} s · {n} action(s)', { state: event.is_error ? L('done_stopped', 'Stopped') : L('done_done', 'Done'), seconds, n: st.tools }) }); st.working = false; st.tools = 0; st.status = ''; return 'items'; } return 'none'; } /** claude.exit {id, code, error} */ function applyExit(st, p) { p = p || {}; st.live = null; st.working = false; if (p.code !== 0 && p.error) return add(st, { role: 'error', text: String(p.error) }); return 'live'; } /** snap {panel:'claude', conv, items}: the whole conversation of a late joiner. */ function applySnap(st, msg) { st.items = Array.isArray(msg.items) ? msg.items.filter((i) => i && typeof i === 'object') : []; if (msg.conv) st.conv = msg.conv; if (msg.session) st.session = msg.session; st.live = null; if (msg.working !== undefined) st.working = !!msg.working; st.gen++; return 'reset'; } const pure = { newState, applyEvent, applyExit, applySnap, toolLabel, toText, streamEvent }; // ---------------------------------------------------------------- DOM part function mount(container) { const R = root.IdeRemote, Md = root.IdeMd; const { el, clear, renderMarkdown } = Md; const st = newState(); let painted = 0, stick = true; const log = el('div.rv-log'); const liveSlot = el('div.rv-live'); const bar = el('div.rv-working', { hidden: true }); const stopBtn = el('button.rv-btn.rv-stop', { type: 'button', onclick: stop }, L('stop', 'Stop')); const barText = el('span.rv-working-text'); bar.append(el('span.rv-spinner'), barText, stopBtn); const input = el('textarea.rv-input', { rows: 1, placeholder: L('message_placeholder', 'Message Claude…'), enterkeyhint: 'enter', autocapitalize: 'sentences', 'aria-label': L('message_aria', 'Message') }); const sendBtn = el('button.rv-btn.rv-send', { type: 'button', onclick: send, 'aria-label': L('send', 'Send') }, L('send', 'Send')); const form = el('div.rv-compose', {}, input, sendBtn); const jump = el('button.rv-jump', { type: 'button', hidden: true, onclick: () => scroll(true) }, L('latest', 'Latest')); clear(container); container.classList.add('rv-claude'); container.append(log, liveSlot, bar, form, jump); log.addEventListener('scroll', () => { stick = log.scrollHeight - log.scrollTop - log.clientHeight < 80; jump.hidden = stick; }, { passive: true }); function scroll(force) { if (force) stick = true; if (stick) requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; jump.hidden = true; }); } function itemNode(item) { switch (item.role) { case 'user': return el('div.rv-msg.rv-user' + (item.during ? '.rv-during' : ''), {}, item.text || ''); case 'assistant': return el('div.rv-msg.rv-assistant.rv-md', {}, ...renderMarkdown(item.text || '')); case 'thinking': return el('details.rv-think', {}, el('summary', {}, L('thinking', 'Thinking')), el('div.rv-think-text', {}, item.text || '')); case 'tool': { const t = toolLabel(item.name, item.input); return el('div.rv-tool', {}, el('b', {}, t.label), el('span', {}, t.what)); } case 'tool-error': return el('div.rv-msg.rv-toolfail', {}, item.text || ''); case 'done': return el('div.rv-done', {}, item.text || ''); case 'note': return el('div.rv-msg.rv-note', {}, item.text || ''); case 'step': return el('div.rv-msg.rv-note', {}, item.text || item.title || L('step', 'Step')); default: return el('div.rv-msg.rv-error', {}, item.text || ''); } } function paintItems(reset) { if (reset) { clear(log); painted = 0; } if (!st.items.length && !painted) { if (!log.querySelector('.rv-empty')) log.append(el('div.rv-empty', {}, L('empty_claude', 'No messages yet. Ask Claude something below.'))); return; } log.querySelector('.rv-empty')?.remove(); for (; painted < st.items.length; painted++) log.append(itemNode(st.items[painted])); } function paintLive() { clear(liveSlot); const l = st.live; if (l) { if (l.kind === 'thinking') liveSlot.append(el('div.rv-msg.rv-live-think', {}, el('b', {}, L('thinking_live', 'Thinking…') + ' '), l.buf.slice(-600))); else if (l.kind === 'text') liveSlot.append(el('div.rv-msg.rv-assistant.rv-md', {}, ...renderMarkdown(l.buf))); else liveSlot.append(el('div.rv-tool', {}, el('b', {}, l.name || L('tool', 'tool')), el('span', {}, '…'))); } bar.hidden = !st.working; barText.textContent = st.status || L('working', 'working…'); sendBtn.textContent = st.working ? L('queue', 'Queue') : L('send', 'Send'); } let raf = 0; function changed(kind) { if (kind === 'none') kind = 'live'; if (raf) { if (kind !== 'live') { cancelAnimationFrame(raf); raf = 0; } else return; } const run = () => { raf = 0; paintItems(kind === 'reset'); paintLive(); scroll(false); }; if (kind === 'live') raf = requestAnimationFrame(run); else run(); } function stop() { R.invoke('claude.stopAll', { conv: st.conv, session: st.session }).catch(() => null); if (st.runId !== null) R.invoke('claude.stop', { id: st.runId }).catch(() => null); } async function send() { const prompt = input.value.trim(); if (!prompt) return; input.value = ''; grow(); changed(add(st, { role: 'user', text: prompt, during: st.working })); st.working = true; st.status = L('sending', 'sending…'); scroll(true); try { const payload = { prompt, session: st.session, conv: st.conv, agent: 'claude' }; const r = await R.invoke('claude.send', payload); if (r && r.id !== undefined) st.runId = r.id; if (r && r.conv && !st.conv) st.conv = r.conv; changed('live'); } catch (e) { st.working = false; st.status = ''; changed(add(st, { role: 'error', text: errText(e) })); } } function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 160) + 'px'; } input.addEventListener('input', grow); input.addEventListener('keydown', (e) => { // a phone's Enter is a new line; a keyboard sends with Ctrl/Cmd+Enter if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); } }); // keep the input above the on-screen keyboard const vv = root.visualViewport; function fitViewport() { if (!vv) return; if (root.document.activeElement === input) { const top = container.getBoundingClientRect().top; container.style.height = Math.max(180, vv.height - Math.max(0, top)) + 'px'; scroll(false); } else container.style.height = ''; } if (vv) { vv.addEventListener('resize', fitViewport); vv.addEventListener('scroll', fitViewport); } input.addEventListener('focus', () => setTimeout(fitViewport, 50)); input.addEventListener('blur', () => setTimeout(fitViewport, 50)); R.on('claude.event', (p) => changed(applyEvent(st, p))); R.on('claude.exit', (p) => changed(applyExit(st, p))); R.on('snap', (m) => { if (m && m.panel === 'claude') changed(applySnap(st, m)); }); paintItems(true); paintLive(); const askSnap = () => { try { if (R.snap) R.snap('claude'); else R.invoke('snap', { panel: 'claude' }).catch(() => null); } catch (e) { /* not connected yet */ } }; R.on('ready', askSnap); if (R.state === 'connected') askSnap(); return { st, focus: () => input.focus() }; } // The shell loads this file as a module and never calls mount(): the view mounts itself in its <section>, loading its libraries first. function boot(id, mount, assets) { const doc = root.document; const load = (src) => { const cache = (root.__rvAssets = root.__rvAssets || {}); if (cache[src]) return cache[src]; return (cache[src] = new Promise((resolve, reject) => { if (src.endsWith('.css')) { const l = doc.createElement('link'); l.rel = 'stylesheet'; l.href = src; l.onload = resolve; l.onerror = resolve; doc.head.append(l); return; } const s = doc.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('cannot load ' + src)); doc.head.append(s); })); }; const run = async () => { const c = doc.getElementById('panel-' + id); if (!c || c.dataset.rvMounted) return; c.dataset.rvMounted = '1'; try { for (const a of assets) await load(a); mount(c); } catch (e) { c.textContent = errText(e); } }; if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', run); else run(); } return { mount, pure, boot, ...pure };});