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 (
)
}
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 {