refactor chat rendering: new message roles, tool call pairing, interruption styling

This commit is contained in:
Levi Neely 2026-05-25 23:12:58 +02:00
parent 6d21f142af
commit aa0e99213d
3 changed files with 118 additions and 36 deletions

View File

@ -244,13 +244,22 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
<div class="message-empty">/s/{sid}/chat</div>
)}
{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 (
<div key={i} class={`message ${msg.role}`}>
<div class="msg-bubble">
{msg.role === 'user' ? (
<div class="md" dangerouslySetInnerHTML={{ __html: linkifyPaths((marked.parse(msg.content) as string).replace(/^<p>/, '<p><span class="msg-prefix">u: </span>'), session.cwd) }} />
) : msg.role === 'call' ? (
<ToolCall name={msg.name ?? ''} args={msg.content} output={messages[i+1]?.role === 'tool' && messages[i+1]?.name === msg.name ? messages[i+1].content : ''} />
) : msg.role === 'tool' ? (
<ToolCall content={msg.content} />
<ToolOutput text={msg.content} />
) : msg.role === 'reasoning' ? (
<details class="reasoning"><summary>thinking</summary><pre>{msg.content}</pre></details>
) : msg.role === 'error' ? (
<div class="msg-error">{msg.content}</div>
) : msg.role === 'info' ? (
<InfoMessage content={msg.content} />
) : (
@ -261,7 +270,8 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
)}
</div>
</div>
))}
)
})}
{isRunning && (
<div class="typing">
@ -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 }) {
<span class="tool-name">shell</span>
<span class="tool-detail">{cmd}</span>
</div>
<pre class="tool-output">{output || '(no output)'}</pre>
<ToolOutput text={output || '(no output)'} />
</div>
)
}
function ToolCall({ content }: { content: string }) {
const { name, detail, body, output } = formatToolContent(content)
function ToolOutput({ text }: { text: string }) {
const parts = text.split(/\n*^<system-user-interruption>$\n?|\n?^<\/system-user-interruption>$\n*/m)
return (
<pre class="tool-output">
{parts.map((p, i) => i % 2 === 1 ? <span class="user-interruption">{p}</span> : p)}
</pre>
)
}
function ToolCall({ name, args, output }: { name: string; args: string; output: string }) {
const { detail, body } = formatToolArgs(name, args)
const [open, setOpen] = useState(false)
return (
<div class="tool-call">
<details open={open} onToggle={(e: Event) => setOpen((e.target as HTMLDetailsElement).open)}>
<summary class="tool-header">
<span class="tool-arrow">{toolIcon(detail)}</span>
{name && <span class="tool-name">{name}</span>}
<span class="tool-arrow">{toolIcon(detail || name)}</span>
<span class="tool-name">{name}</span>
{detail && <span class="tool-detail">{detail}</span>}
</summary>
<pre class="tool-body">{body}</pre>
</details>
{output && <pre class="tool-output">{output}</pre>}
{output && <ToolOutput text={output} />}
</div>
)
}

View File

@ -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)

View File

@ -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 {