/** * Remote Control — Event Rendering * * Renders session events into DOM elements for the event stream. */ import { esc } from "./utils.js"; import { processAssistantEvent } from "./task-panel.js"; // ============================================================ // Replay state — tracks unresolved permission requests during history replay // ============================================================ const replayPendingRequests = new Map(); // request_id → event data (unresolved) const replayRespondedRequests = new Set(); // request_ids that have a response /** Clear replay tracking state (call before each history load) */ export function resetReplayState() { replayPendingRequests.clear(); replayRespondedRequests.clear(); } /** After replay finishes, render any still-unresolved permission prompts */ export function renderReplayPendingRequests() { if (replayPendingRequests.size === 0) return; // Sort by seqNum to maintain order const sorted = [...replayPendingRequests.entries()].sort((a, b) => (a[1].seqNum || 0) - (b[1].seqNum || 0)); for (const [, data] of sorted) { // Re-invoke appendEvent without replay flag to go through the normal interactive path appendEvent(data, { replay: false }); } replayPendingRequests.clear(); } // ============================================================ // Helpers // ============================================================ function truncate(str, max) { if (!str) return ""; const s = String(str); return s.length > max ? s.slice(0, max) + "..." : s; } /** * Extract plain text from an event payload. * Server-side normalization guarantees payload.content is a string. * Falls back to raw/message parsing for backward compat. */ export function extractText(payload) { if (!payload) return ""; // Normalized format (server standardized) if (typeof payload.content === "string" && payload.content) return payload.content; // Fallback: raw message.content (child process format) const msg = payload.message; if (msg && typeof msg === "object") { const mc = msg.content; if (typeof mc === "string") return mc; if (Array.isArray(mc)) { return mc .filter((b) => b && typeof b === "object" && b.type === "text") .map((b) => b.text || "") .join(""); } } // Final fallback return typeof payload === "string" ? payload : JSON.stringify(payload); } function formatAssistantContent(content) { let html = esc(content); // Code blocks: ```...``` html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { return `
${code.trim()}
`; }); // Inline code: `...` html = html.replace(/`([^`]+)`/g, '$1'); // Bold: **...** html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); return html; } // ============================================================ // Event Router // ============================================================ export function appendEvent(data, { replay = false } = {}) { const stream = document.getElementById("event-stream"); if (!stream) return; const type = data.type || "unknown"; const payload = data.payload || {}; const direction = data.direction || "inbound"; // Early filter: skip bridge init noise regardless of event type const serialized = JSON.stringify(data); if (/Remote Control connecting/i.test(serialized)) return; // During history replay, only render messages & tools — skip interactive/stateful events // Exception: unresolved permission/control requests are re-shown as pending prompts. if (replay) { let histEl; switch (type) { case "user": if (direction === "outbound") histEl = renderUserMessage(payload, direction); break; case "assistant": { const text = extractText(payload); if (text && text.trim()) histEl = renderAssistantMessage(payload); processAssistantEvent(payload); } break; case "tool_use": histEl = renderToolUse(payload); break; case "tool_result": histEl = renderToolResult(payload); break; case "error": histEl = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`); break; case "control_request": case "permission_request": // Track unanswered permission/control requests for replay if (payload.request && payload.request.subtype === "can_use_tool" && direction === "inbound") { const rid = payload.request_id || data.id; if (rid && !replayRespondedRequests.has(rid)) { replayPendingRequests.set(rid, data); } } return; case "control_response": case "permission_response": // Mark the corresponding request as resolved { const respRid = payload.request_id; if (respRid) { replayRespondedRequests.add(respRid); replayPendingRequests.delete(respRid); } } return; // Skip: partial_assistant, result, status, interrupt, system, user inbound echoes default: return; } if (histEl) { stream.appendChild(histEl); stream.scrollTop = stream.scrollHeight; } return; } let el; let needLoading = false; switch (type) { case "user": // Skip inbound user messages — they're echoes of what we already sent if (direction === "inbound") return; el = renderUserMessage(payload, direction); needLoading = true; break; case "partial_assistant": // Skip partial assistant — wait for the final "assistant" event // to avoid blank/duplicate messages during streaming return; case "assistant": removeLoading(); { const text = extractText(payload); if (text && text.trim()) el = renderAssistantMessage(payload); processAssistantEvent(payload); } break; case "result": case "result_success": removeLoading(); // Skip result — it just repeats the assistant message content return; case "tool_use": el = renderToolUse(payload); break; case "tool_result": el = renderToolResult(payload); break; case "control_request": case "permission_request": if (payload.request && payload.request.subtype === "can_use_tool") { const toolName = payload.request.tool_name || "unknown"; const toolInput = payload.request.input || payload.request.tool_input || {}; if (toolName === "AskUserQuestion") { el = renderAskUserQuestion({ request_id: payload.request_id || data.id, tool_input: toolInput, description: payload.request.description || "", }); } else if (toolName === "ExitPlanMode") { el = renderExitPlanMode({ request_id: payload.request_id || data.id, tool_input: toolInput, description: payload.request.description || "", }); } else { el = renderPermissionRequest({ request_id: payload.request_id || data.id, tool_name: toolName, tool_input: toolInput, description: payload.request.description || "", }); } } else { el = renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`); } break; case "control_response": case "permission_response": // Skip — these are just acknowledgments, no need to show in stream return; case "status": // Skip connecting/waiting status noise from bridge { const msg = payload.message || payload.content || ""; const fullText = typeof payload === "string" ? payload : JSON.stringify(payload); if (/connecting|waiting|initializing|Remote Control/i.test(msg + " " + fullText)) return; if (!msg.trim()) return; el = renderSystemMessage(msg); } break; case "error": removeLoading(); el = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`); break; case "interrupt": removeLoading(); el = renderSystemMessage("Session interrupted"); break; case "system": // Skip raw system/init messages — they're noise return; default: { // Skip noise from bridge init const raw = JSON.stringify(payload); if (/Remote Control connecting/i.test(raw)) return; el = renderSystemMessage(`${type}: ${truncate(raw, 200)}`); } } if (el) { stream.appendChild(el); stream.scrollTop = stream.scrollHeight; } // Show loading after the message element is in the DOM so it renders below if (needLoading) showLoading(); } // ============================================================ // Renderers // ============================================================ function renderUserMessage(payload, direction) { const content = extractText(payload); const row = document.createElement("div"); row.className = "msg-row user"; row.innerHTML = `
${esc(content)}
`; return row; } function renderAssistantMessage(payload) { const content = extractText(payload); const row = document.createElement("div"); row.className = "msg-row assistant"; row.innerHTML = `
${formatAssistantContent(content)}
`; return row; } function renderResult(payload) { const text = payload.result || payload.subtype || "Session completed"; const row = document.createElement("div"); row.className = "msg-row system result"; row.innerHTML = `
✓ ${esc(text)}
`; return row; } function renderToolUse(payload) { const name = payload.tool_name || payload.name || "tool"; const input = payload.tool_input || payload.input || {}; const inputStr = typeof input === "string" ? input : JSON.stringify(input, null, 2); const card = document.createElement("div"); card.className = "msg-row tool"; card.innerHTML = `
Tool: ${esc(name)}
`; return card; } function renderToolResult(payload) { const content = payload.content || payload.output || ""; const contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2); const card = document.createElement("div"); card.className = "msg-row tool"; card.innerHTML = `
Tool Result
`; return card; } export function renderPermissionRequest(payload) { const requestId = payload.request_id || payload.id || ""; const toolName = payload.tool_name || "unknown"; const toolInput = payload.tool_input || payload.input || {}; const description = payload.description || ""; const inputStr = typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2); const area = document.getElementById("permission-area"); area.classList.remove("hidden"); const el = document.createElement("div"); el.className = "permission-prompt"; el.dataset.requestId = requestId; el.innerHTML = `
Permission Request
${description ? `
${esc(description)}
` : ""}
${esc(toolName)}
${toolName !== "AskUserQuestion" ? `
${esc(truncate(inputStr, 500))}
` : ""}
`; area.appendChild(el); return renderSystemMessage(`Permission requested: ${toolName}`); } export function renderAskUserQuestion(payload) { const requestId = payload.request_id || payload.id || ""; const questions = payload.tool_input?.questions || []; const description = payload.description || ""; const area = document.getElementById("permission-area"); area.classList.remove("hidden"); const el = document.createElement("div"); el.className = "ask-panel"; el.dataset.requestId = requestId; // Single question — no tabs needed if (questions.length <= 1) { const q = questions[0] || {}; const multiSelect = q.multiSelect || false; el.innerHTML = `
${esc(description || q.question || "Question")}
${(q.options || []).map((opt, j) => ` `).join("")}
`; } else { // Multiple questions — tab layout const tabs = questions.map((q, i) => { const multiSelect = q.multiSelect || false; return `
${esc(q.question || "")}
${q.header ? `
${esc(q.header)}
` : ""}
${(q.options || []).map((opt, j) => ` `).join("")}
`; }).join(""); const tabBar = questions.map((q, i) => `` ).join(""); el.innerHTML = `
${esc(description || "Questions")}
${tabBar}
${tabs} `; } area.appendChild(el); // Track selected options and store original questions for answer mapping el._answers = {}; el._questions = questions; return renderSystemMessage("Waiting for your response..."); } export function renderExitPlanMode(payload) { const requestId = payload.request_id || payload.id || ""; const toolInput = payload.tool_input || {}; const description = payload.description || ""; const planContent = toolInput.plan || ""; const area = document.getElementById("permission-area"); area.classList.remove("hidden"); const el = document.createElement("div"); el.className = "plan-panel"; el.dataset.requestId = requestId; const isEmpty = !planContent || !planContent.trim(); if (isEmpty) { el.innerHTML = `
Exit plan mode?
`; } else { el.innerHTML = `
Ready to code?
${formatAssistantContent(planContent)}
`; } area.appendChild(el); el._selectedValue = null; el._planContent = planContent; el._isEmpty = isEmpty; return renderSystemMessage("Waiting for your response..."); } function renderSystemMessage(text) { const row = document.createElement("div"); row.className = "msg-row system"; row.innerHTML = `
${esc(text)}
`; return row; } // ============================================================ // Loading Indicator — TUI star spinner style // ============================================================ const LOADING_ID = "loading-indicator"; // TUI star spinner frames (same as Claude Code CLI) const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽"]; const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()]; // 204 verbs from TUI src/constants/spinnerVerbs.ts const SPINNER_VERBS = [ "Accomplishing","Actioning","Actualizing","Architecting","Baking","Beaming", "Beboppin'","Befuddling","Billowing","Blanching","Bloviating","Boogieing", "Boondoggling","Booping","Bootstrapping","Brewing","Bunning","Burrowing", "Calculating","Canoodling","Caramelizing","Cascading","Catapulting","Cerebrating", "Channeling","Channelling","Choreographing","Churning","Clauding","Coalescing", "Cogitating","Combobulating","Composing","Computing","Concocting","Considering", "Contemplating","Cooking","Crafting","Creating","Crunching","Crystallizing", "Cultivating","Deciphering","Deliberating","Determining","Dilly-dallying", "Discombobulating","Doing","Doodling","Drizzling","Ebbing","Effecting", "Elucidating","Embellishing","Enchanting","Envisioning","Evaporating", "Fermenting","Fiddle-faddling","Finagling","Flambéing","Flibbertigibbeting", "Flowing","Flummoxing","Fluttering","Forging","Forming","Frolicking","Frosting", "Gallivanting","Galloping","Garnishing","Generating","Gesticulating", "Germinating","Gitifying","Grooving","Gusting","Harmonizing","Hashing", "Hatching","Herding","Honking","Hullaballooing","Hyperspacing","Ideating", "Imagining","Improvising","Incubating","Inferring","Infusing","Ionizing", "Jitterbugging","Julienning","Kneading","Leavening","Levitating","Lollygagging", "Manifesting","Marinating","Meandering","Metamorphosing","Misting","Moonwalking", "Moseying","Mulling","Mustering","Musing","Nebulizing","Nesting","Newspapering", "Noodling","Nucleating","Orbiting","Orchestrating","Osmosing","Perambulating", "Percolating","Perusing","Philosophising","Photosynthesizing","Pollinating", "Pondering","Pontificating","Pouncing","Precipitating","Prestidigitating", "Processing","Proofing","Propagating","Puttering","Puzzling","Quantumizing", "Razzle-dazzling","Razzmatazzing","Recombobulating","Reticulating","Roosting", "Ruminating","Sautéing","Scampering","Schlepping","Scurrying","Seasoning", "Shenaniganing","Shimmying","Simmering","Skedaddling","Sketching","Slithering", "Smooshing","Sock-hopping","Spelunking","Spinning","Sprouting","Stewing", "Sublimating","Swirling","Swooping","Symbioting","Synthesizing","Tempering", "Thinking","Thundering","Tinkering","Tomfoolering","Topsy-turvying", "Transfiguring","Transmuting","Twisting","Undulating","Unfurling","Unravelling", "Vibing","Waddling","Wandering","Warping","Whatchamacalliting","Whirlpooling", "Whirring","Whisking","Wibbling","Working","Wrangling","Zesting","Zigzagging", ]; // Animation state let spinnerInterval = null; let timerInterval = null; let stalledCheckInterval = null; let spinnerFrame = 0; let loadingStartTime = 0; let lastActivityTime = 0; let isStalled = false; let loadingActive = false; export function isLoading() { return loadingActive; } function syncActionBtn(state) { if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(state); } export function showLoading() { removeLoading(); const stream = document.getElementById("event-stream"); if (!stream) return; loadingActive = true; syncActionBtn(true); const verb = SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)]; loadingStartTime = Date.now(); lastActivityTime = Date.now(); isStalled = false; const el = document.createElement("div"); el.id = LOADING_ID; el.className = "msg-row loading-row"; el.innerHTML = `${SPINNER_CYCLE[0]}${esc(verb)}…0s`; stream.appendChild(el); stream.scrollTop = stream.scrollHeight; const spinnerEl = el.querySelector(".tui-spinner"); const timerEl = el.querySelector(".tui-timer"); const loadingEl = el; // Spinner animation — 120ms interval, same as TUI spinnerFrame = 0; spinnerInterval = setInterval(() => { spinnerFrame = (spinnerFrame + 1) % SPINNER_CYCLE.length; if (spinnerEl) spinnerEl.textContent = SPINNER_CYCLE[spinnerFrame]; }, 120); // Timer — update every second timerInterval = setInterval(() => { if (timerEl) { const elapsed = Math.floor((Date.now() - loadingStartTime) / 1000); timerEl.textContent = `${elapsed}s`; } }, 1000); // Stalled detection — check every 120ms (aligned with spinner) stalledCheckInterval = setInterval(() => { if (!isStalled && Date.now() - lastActivityTime > 3000) { isStalled = true; if (loadingEl) loadingEl.classList.add("stalled"); } }, 120); } export function removeLoading() { if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; } if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; } isStalled = false; loadingActive = false; syncActionBtn(false); const el = document.getElementById(LOADING_ID); if (el) el.remove(); } /** Reset stalled timer — call when SSE events arrive */ export function refreshLoadingActivity() { lastActivityTime = Date.now(); if (isStalled) { isStalled = false; const loadingEl = document.getElementById(LOADING_ID); if (loadingEl) loadingEl.classList.remove("stalled"); } }