로그인
KO
apps/jumphp-ide-remote / ui / js / views / claude.js · commit b81e652
// 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 };});