diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js
index f3eab5a..0968167 100644
--- a/internal/webui/static/app.js
+++ b/internal/webui/static/app.js
@@ -82,6 +82,82 @@ function setStatus(elem, state, text) {
elem.textContent = text;
}
+// ---- lightweight markdown → HTML ----
+
+function renderMarkdown(text) {
+ // Escape HTML so LLM output can never inject raw tags.
+ let html = text
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+
+ // Protect fenced code blocks (``` … ```)
+ const codeBlocks = [];
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
+ codeBlocks.push(`
${code}
`);
+ return `\x00CB${codeBlocks.length - 1}\x00`;
+ });
+
+ // Protect inline code (`…`)
+ const inlineCodes = [];
+ html = html.replace(/`([^`\n]+)`/g, (_, code) => {
+ inlineCodes.push(`${code}`);
+ return `\x00IC${inlineCodes.length - 1}\x00`;
+ });
+
+ // Bold then italic (order matters)
+ html = html.replace(/\*\*(.+?)\*\*/g, "$1");
+ html = html.replace(/(?$1");
+
+ // Headers
+ html = html.replace(/^#### (.+)$/gm, "$1
");
+ html = html.replace(/^### (.+)$/gm, "$1
");
+ html = html.replace(/^## (.+)$/gm, "$1
");
+ html = html.replace(/^# (.+)$/gm, "$1
");
+
+ // Horizontal rules
+ html = html.replace(/^---+$/gm, "
");
+
+ // Links — only allow http/https to prevent javascript: URIs
+ html = html.replace(
+ /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
+ '$1',
+ );
+
+ // Unordered lists: consecutive lines starting with - or *
+ html = html.replace(/(?:^[*\-] .+(?:\n|$))+/gm, (match) => {
+ const items = match
+ .trim()
+ .split("\n")
+ .map((l) => `${l.replace(/^[*\-] /, "")}`)
+ .join("");
+ return ``;
+ });
+
+ // Ordered lists: consecutive lines starting with 1. 2. etc.
+ html = html.replace(/(?:^\d+\. .+(?:\n|$))+/gm, (match) => {
+ const items = match
+ .trim()
+ .split("\n")
+ .map((l) => `${l.replace(/^\d+\. /, "")}`)
+ .join("");
+ return `${items}
`;
+ });
+
+ // Convert remaining newlines to
+ html = html.replace(/\n/g, "
");
+
+ // Clean stray
adjacent to block elements
+ html = html.replace(/
(<\/?(?:h[2-5]|pre|ul|ol|li|hr))/g, "$1");
+ html = html.replace(/((?:<\/(?:h[2-5]|pre|ul|ol|li)>|
))
/g, "$1");
+
+ // Restore protected code
+ html = html.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[i]);
+ html = html.replace(/\x00IC(\d+)\x00/g, (_, i) => inlineCodes[i]);
+
+ return html;
+}
+
// ---- settings panel wiring ----
function initSettings() {
@@ -278,9 +354,11 @@ const fleet = (() => {
if (err.name !== "AbortError") console.warn("fleet stream:", err);
}
- // Refresh heartbeat ages so the pills change colour even when no events
- // arrive (e.g. a totally idle solo fleet).
- heartbeatTimer = setInterval(render, 5000);
+ // Periodically re-fetch peer data so status stays accurate even if the
+ // SSE stream drops silently or heartbeats lag.
+ heartbeatTimer = setInterval(() => {
+ loadInitial();
+ }, 5000);
}
function disconnect() {
@@ -445,7 +523,12 @@ const chat = (() => {
let busy = false;
function appendBubble(role, text) {
- const node = el("div", { class: `bubble ${role}` }, text);
+ const node = el("div", { class: `bubble ${role}` });
+ if (role === "assistant") {
+ node.innerHTML = renderMarkdown(text);
+ } else {
+ node.textContent = text;
+ }
$("#chat-transcript").appendChild(node);
scrollToEnd();
return node;
@@ -721,7 +804,7 @@ function initChat() {
});
input.addEventListener("keydown", (e) => {
- if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html
index 4f2d023..df2ba45 100644
--- a/internal/webui/static/index.html
+++ b/internal/webui/static/index.html
@@ -73,7 +73,7 @@ Debug chat
diff --git a/internal/webui/static/style.css b/internal/webui/static/style.css
index 8a4d772..ab32587 100644
--- a/internal/webui/static/style.css
+++ b/internal/webui/static/style.css
@@ -277,8 +277,75 @@ input:focus, select:focus, textarea:focus {
.bubble.assistant {
align-self: flex-start;
background: var(--assistant);
+ white-space: normal;
}
+/* ---- rendered markdown inside assistant bubbles ---- */
+
+.bubble.assistant h2,
+.bubble.assistant h3,
+.bubble.assistant h4,
+.bubble.assistant h5 {
+ margin: 8px 0 4px;
+ line-height: 1.3;
+}
+.bubble.assistant h2 { font-size: 16px; }
+.bubble.assistant h3 { font-size: 15px; }
+.bubble.assistant h4 { font-size: 14px; font-weight: 600; }
+.bubble.assistant h5 { font-size: 13px; font-weight: 600; }
+
+.bubble.assistant h2:first-child,
+.bubble.assistant h3:first-child,
+.bubble.assistant h4:first-child,
+.bubble.assistant h5:first-child { margin-top: 0; }
+
+.bubble.assistant code {
+ background: var(--bg);
+ padding: 1px 5px;
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.bubble.assistant pre {
+ background: var(--bg);
+ padding: 10px 12px;
+ border-radius: 6px;
+ overflow-x: auto;
+ margin: 6px 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.bubble.assistant pre code {
+ background: none;
+ padding: 0;
+ border-radius: 0;
+}
+
+.bubble.assistant ul,
+.bubble.assistant ol {
+ margin: 4px 0;
+ padding-left: 20px;
+}
+
+.bubble.assistant li {
+ margin: 2px 0;
+}
+
+.bubble.assistant hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 8px 0;
+}
+
+.bubble.assistant a {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.bubble.assistant strong { font-weight: 600; }
+.bubble.assistant em { font-style: italic; }
+
.bubble.error {
align-self: stretch;
background: var(--bad);