From aa0e99213dffc26c8ebb54b13946afefbc205b29 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Mon, 25 May 2026 23:12:58 +0200 Subject: [PATCH] refactor chat rendering: new message roles, tool call pairing, interruption styling --- src/components/ChatPane.tsx | 51 +++++++++++++++---------- src/lib/chat.ts | 29 +++++++-------- src/style.css | 74 +++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 36 deletions(-) diff --git a/src/components/ChatPane.tsx b/src/components/ChatPane.tsx index 499a30d..ce4f202 100644 --- a/src/components/ChatPane.tsx +++ b/src/components/ChatPane.tsx @@ -244,13 +244,22 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
/s/{sid}/chat
)} - {messages.map((msg, i) => ( + {messages.map((msg, i) => { + // tool results rendered inline with their preceding call + if (msg.role === 'tool' && i > 0 && messages[i-1]?.role === 'call' && messages[i-1]?.name === msg.name) return null + return (
{msg.role === 'user' ? (
/, '

u: '), session.cwd) }} /> + ) : msg.role === 'call' ? ( + ) : msg.role === 'tool' ? ( - + + ) : msg.role === 'reasoning' ? ( +

thinking
{msg.content}
+ ) : msg.role === 'error' ? ( +
{msg.content}
) : msg.role === 'info' ? ( ) : ( @@ -261,7 +270,8 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b )}
- ))} + ) + })} {isRunning && (
@@ -361,15 +371,7 @@ function DiagramModal({ html, onClose }: { html: string; onClose: () => void }) ) } -function formatToolContent(raw: string): { name: string; detail: string; body: string; output: string } { - // Call is always on the first line; output follows after the newline. - const nl = raw.indexOf("\n") - const callLine = nl === -1 ? raw : raw.slice(0, nl) - const output = nl === -1 ? "" : raw.slice(nl + 1).trimEnd() - const m = callLine.match(/^(\w+)\((.*)\)$/) - if (!m) return { name: "", detail: "", body: raw, output: "" } - const name = m[1] - const argsRaw = m[2] +function formatToolArgs(name: string, argsRaw: string): { detail: string; body: string } { try { const obj = JSON.parse(argsRaw) let detail = "" @@ -381,9 +383,9 @@ function formatToolContent(raw: string): { name: string; detail: string; body: s } else { detail = obj.steps?.[0]?.language ?? "bash" } - return { name, detail, body: JSON.stringify(obj, null, 2), output } + return { detail, body: JSON.stringify(obj, null, 2) } } catch { - return { name, detail: "", body: argsRaw, output } + return { detail: "", body: argsRaw } } } @@ -429,25 +431,34 @@ function InfoMessage({ content }: { content: string }) { shell {cmd}
-
{output || '(no output)'}
+
) } -function ToolCall({ content }: { content: string }) { - const { name, detail, body, output } = formatToolContent(content) +function ToolOutput({ text }: { text: string }) { + const parts = text.split(/\n*^$\n?|\n?^<\/system-user-interruption>$\n*/m) + return ( +
+      {parts.map((p, i) => i % 2 === 1 ? {p} : p)}
+    
+ ) +} + +function ToolCall({ name, args, output }: { name: string; args: string; output: string }) { + const { detail, body } = formatToolArgs(name, args) const [open, setOpen] = useState(false) return (
setOpen((e.target as HTMLDetailsElement).open)}> - {toolIcon(detail)} - {name && {name}} + {toolIcon(detail || name)} + {name} {detail && {detail}}
{body}
- {output &&
{output}
} + {output && }
) } diff --git a/src/lib/chat.ts b/src/lib/chat.ts index 5f08377..a624faf 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -1,30 +1,27 @@ +export type ChatRole = 'user' | 'assistant' | 'reasoning' | 'call' | 'tool' | 'error' | 'info' | 'retry' | 'exec' | 'stalled' | 'maxsteps' + export interface ChatMessage { - role: 'user' | 'assistant' | 'tool' | 'info' + role: ChatRole + name?: string content: string } -// Parse the ollie chat log format (lines returned by the gateway). -// Lines starting with "user: " or "assistant: " begin a new message; -// subsequent lines are appended to the current message content. +const headerRe = /^\[(\w+)(?::([^\]]+))?\]$/ + +// Parse the ollie chat log format. Each block starts with a [role] or +// [role:name] header line; content runs until the next header. export function parseChat(lines: string[]): ChatMessage[] { const messages: ChatMessage[] = [] let cur: ChatMessage | null = null for (const line of lines) { - if (line.startsWith('user: ')) { + const m = headerRe.exec(line) + if (m) { if (cur) messages.push(cur) - cur = { role: 'user', content: line.slice(6) } - } else if (line.startsWith('assistant: ')) { - if (cur) messages.push(cur) - cur = { role: 'assistant', content: line.slice(11) } - } else if (line.startsWith('-> ')) { - if (cur) messages.push(cur) - cur = { role: 'tool', content: line.slice(3) } - } else if (line.startsWith(':: ')) { - if (cur) messages.push(cur) - cur = { role: 'info', content: line.slice(3) } + cur = { role: m[1] as ChatRole, content: '' } + if (m[2]) cur.name = m[2] } else if (cur) { - cur.content += '\n' + line + cur.content += (cur.content ? '\n' : '') + line } } if (cur) messages.push(cur) diff --git a/src/style.css b/src/style.css index 361819d..e7520b3 100644 --- a/src/style.css +++ b/src/style.css @@ -552,6 +552,16 @@ input, select, textarea { font-family: inherit; } .md th { background: var(--bg-soft); font-weight: 600; } /* ---- Tool calls ---- */ +.message.call { justify-content: flex-start; } +.message.call .msg-bubble { + max-width: 100%; + width: 100%; + background: var(--tool-bubble-bg); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 0; + overflow: hidden; +} .message.tool { justify-content: flex-start; } .message.tool .msg-bubble { max-width: 100%; @@ -812,6 +822,22 @@ details[open] > .tool-header::before { padding: 0; margin: 4px 0; } +[data-theme="acme"] .message.call .msg-bubble { + border-radius: var(--r-sm); + margin: 4px 0; +} +[data-theme="acme"] .message.reasoning .msg-bubble { + padding: 0; +} +[data-theme="acme"] .message.error .msg-bubble { + border-radius: var(--r-sm); + margin: 4px 0; +} +[data-theme="acme"] .user-interruption { + background: rgba(147, 112, 219, 0.12); + border-color: rgba(147, 112, 219, 0.4); + color: #6b21a8; +} [data-theme="acme"] .shell-block { background: #fff; border: 1px solid var(--border); @@ -1072,6 +1098,54 @@ details[open] > .tool-header::before { overflow-x: auto; } +/* ---- Reasoning ---- */ +.message.reasoning { justify-content: flex-start; } +.message.reasoning .msg-bubble { + background: transparent; + border: none; + padding: 2px 8px; +} +.reasoning summary { + font-family: var(--mono); + font-size: 12px; + color: var(--text-faint); + cursor: pointer; +} +.reasoning pre { + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + color: var(--text-dim); + white-space: pre-wrap; + margin: 4px 0 0; +} + +/* ---- Error messages ---- */ +.message.error { justify-content: flex-start; } +.message.error .msg-bubble { + background: rgba(251, 113, 133, 0.06); + border: 1px solid rgba(251, 113, 133, 0.25); + border-radius: var(--r-md); +} +.msg-error { + font-family: var(--mono); + font-size: 12px; + color: var(--danger); +} + +/* ---- User interruption ---- */ +.user-interruption { + display: block; + margin: 6px 0 2px; + padding: 6px 10px; + background: rgba(139, 92, 246, 0.08); + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: var(--r-sm); + color: #b49aef; + font-style: normal; + white-space: pre-wrap; +} + /* ---- Info messages ---- */ .message.info { justify-content: flex-start; } .message.info .msg-bubble {