diff --git a/client/index.html b/client/index.html
index f753e61..3776b26 100644
--- a/client/index.html
+++ b/client/index.html
@@ -5,6 +5,7 @@
diff --git a/client/js/app.js b/client/js/app.js
index b8027f2..09fe036 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -1,8 +1,8 @@
import { api } from './api.js';
import { WsClient } from './ws.js';
-import { appendMessage, appendToolCall, appendTypingIndicator,
- removeTypingIndicator, appendError, showEmptyState,
- scrollToBottom } from './chat.js';
+import { appendMessage, appendStreamBubble, finalizeStreamBubble,
+ appendToolCall, appendTypingIndicator, removeTypingIndicator,
+ appendError, showEmptyState, scrollToBottom } from './chat.js';
import { renderProfiles, renderSessions, updateChatHeader } from './sidebar.js';
// ── DOM refs ─────────────────────────────────────────────────────────────────
@@ -132,8 +132,7 @@
case 'stream_start':
streaming = true;
removeTypingIndicator(messagesEl);
- currentBubble = appendMessage(messagesEl, 'assistant', '');
- currentBubble.classList.add('cursor');
+ currentBubble = appendStreamBubble(messagesEl);
break;
case 'stream_delta':
@@ -165,10 +164,11 @@
streaming = false;
removeTypingIndicator(messagesEl);
if (currentBubble) {
- currentBubble.classList.remove('cursor');
if (finalContent !== undefined) {
- currentBubble.textContent = finalContent;
+ finalizeStreamBubble(currentBubble, finalContent);
updatePreview(currentId, finalContent);
+ } else {
+ currentBubble.classList.remove('cursor');
}
currentBubble = null;
}
diff --git a/client/js/chat.js b/client/js/chat.js
index 903ac47..9005d7e 100644
--- a/client/js/chat.js
+++ b/client/js/chat.js
@@ -1,4 +1,24 @@
-/** Chat area DOM helpers. All functions receive the messages container element. */
+/** Chat area DOM helpers. */
+
+import { marked } from 'https://esm.sh/marked@12';
+import hljs from 'https://esm.sh/highlight.js@11';
+
+// ── Markdown setup ────────────────────────────────────────────────────────────
+
+marked.use({
+ gfm: true,
+ breaks: true,
+});
+
+function renderMarkdown(text) {
+ const div = document.createElement('div');
+ div.className = 'prose';
+ div.innerHTML = marked.parse(text);
+ div.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
+ return div;
+}
+
+// ── Tool icons ────────────────────────────────────────────────────────────────
const TOOL_ICONS = {
web_search: '🔍',
@@ -9,6 +29,8 @@
ssh_exec: '🖧',
};
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
function esc(str) {
return String(str ?? '')
.replace(/&/g, '&').replace(/ `
${esc(k)}${esc(JSON.stringify(v))}`)
+ .join('');
+
+ const card = document.createElement('details');
+ card.className = `tool-card${success ? '' : ' error'}`;
card.innerHTML = `
-
-
${esc(event.result)}
`;
+
${esc(event.tool)}
+
${success ? '✓' : '✗'}
+
+
+ ${argsLines ? `
${argsLines}
` : ''}
+
${esc(event.result)}
+
`;
el.appendChild(card);
}
diff --git a/client/style.css b/client/style.css
index c5f2136..4c38303 100644
--- a/client/style.css
+++ b/client/style.css
@@ -189,31 +189,104 @@
line-height: 1.55;
box-shadow: var(--shadow);
word-break: break-word;
- white-space: pre-wrap;
+ /* no white-space: pre-wrap — markdown handles this */
}
-.msg.user .bubble { background: var(--user-bubble); color: var(--user-text); border-bottom-right-radius: 3px; }
+.msg.user .bubble { background: var(--user-bubble); color: var(--user-text); border-bottom-right-radius: 3px; white-space: pre-wrap; }
.msg.assistant .bubble { background: var(--bot-bubble); color: var(--bot-text); border-bottom-left-radius: 3px; }
.msg-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; padding: 0 2px; }
-/* Tool call card */
+/* ── Markdown prose ───────────────────────────────── */
+
+.prose { line-height: 1.6; }
+.prose p { margin: 0 0 0.6em; }
+.prose p:last-child { margin-bottom: 0; }
+.prose h1,.prose h2,.prose h3,.prose h4 { font-weight: 700; margin: 0.8em 0 0.3em; line-height: 1.25; }
+.prose h1 { font-size: 1.4em; } .prose h2 { font-size: 1.2em; } .prose h3 { font-size: 1.05em; }
+.prose ul,.prose ol { padding-left: 1.4em; margin: 0.4em 0; }
+.prose li { margin: 0.15em 0; }
+.prose code { font-family: "Fira Code", "Cascadia Code", ui-monospace, monospace; font-size: 0.85em;
+ background: #2a2a2a; color: #e2b97e; padding: 1px 5px; border-radius: 4px; }
+.prose pre { margin: 0.6em 0; border-radius: 8px; overflow: hidden; }
+.prose pre code { background: none; color: inherit; padding: 0; border-radius: 0; font-size: 0.82em; }
+.prose pre .hljs { padding: 12px 16px; border-radius: 8px; }
+.prose blockquote { border-left: 3px solid #444; margin: 0.5em 0; padding: 0.2em 0 0.2em 0.8em; color: var(--text-muted); }
+.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.9em; }
+.prose th,.prose td { border: 1px solid #333; padding: 5px 10px; text-align: left; }
+.prose th { background: #222; }
+.prose a { color: #60a5fa; text-decoration: none; }
+.prose a:hover { text-decoration: underline; }
+.prose hr { border: none; border-top: 1px solid #333; margin: 0.8em 0; }
+.prose strong { font-weight: 700; }
+.prose em { font-style: italic; }
+
+/* ── Tool call card (accordion) ──────────────────── */
+
.tool-card {
align-self: flex-start;
- max-width: 80%;
+ max-width: 84%;
background: var(--tool-bg);
border: 1px solid var(--tool-border);
border-radius: var(--radius);
- padding: 8px 12px;
font-size: 12px;
color: var(--tool-text);
+ overflow: hidden;
+}
+.tool-card.error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
+
+.tool-card summary { list-style: none; }
+.tool-card summary::-webkit-details-marker { display: none; }
+
+.tool-header {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 8px 12px;
+ cursor: pointer;
+ user-select: none;
+ font-weight: 600;
+}
+.tool-header:hover { background: rgba(255,255,255,0.04); }
+.tool-icon { font-size: 14px; }
+.tool-name { flex: 1; }
+.tool-status { font-size: 13px; opacity: 0.8; }
+.tool-card:not([open]) .tool-header::after { content: '›'; font-size: 16px; opacity: 0.5; }
+.tool-card[open] .tool-header::after { content: '‹'; font-size: 16px; opacity: 0.5; }
+
+.tool-body {
+ border-top: 1px solid var(--tool-border);
+ padding: 8px 12px;
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 6px;
+ animation: fadeSlide 0.18s ease;
}
-.tool-card .tool-header { display: flex; align-items: center; gap: 6px; font-weight: 600; }
-.tool-card .tool-icon { font-size: 14px; }
-.tool-card .tool-result { color: var(--text-muted); font-size: 11px; margin-top: 2px; max-height: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.tool-card.error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
+@keyframes fadeSlide { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
+
+.tool-args {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 2px 10px;
+ font-size: 11px;
+ opacity: 0.85;
+}
+.arg-key { color: var(--text-muted); font-style: italic; }
+.arg-val { word-break: break-all; }
+
+.tool-result-pre {
+ margin: 0;
+ padding: 8px 10px;
+ background: rgba(0,0,0,0.25);
+ border-radius: 6px;
+ font-family: ui-monospace, monospace;
+ font-size: 11px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 260px;
+ overflow-y: auto;
+ color: var(--text);
+}
/* Typing indicator */
.typing {