Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
492946e0f1 fix: refine chat tool/thinking rendering (#1292) (thanks @bradleypriest) 2026-01-20 11:56:52 +00:00
Bradley Priest
e82e16cada ui(chat): separate tool/thinking output and add toggle
- Render assistant reasoning as a distinct block (not merged into message text).\n- Detect tool-like messages reliably and style them separately.\n- Add a "🧠" toggle to hide/show tool + thinking output, persisted in UI settings.
2026-01-20 11:45:38 +00:00
14 changed files with 288 additions and 160 deletions

View File

@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- UI: separate tool output from assistant reasoning and add a thinking toggle. (#1292) — thanks @bradleypriest.
### Fixes ### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.

View File

@ -84,6 +84,11 @@
color: rgba(150, 150, 150, 1); color: rgba(150, 150, 150, 1);
} }
.chat-avatar.tool {
background: rgba(134, 142, 150, 0.2);
color: rgba(134, 142, 150, 1);
}
/* Minimal Bubble Design - dynamic width based on content */ /* Minimal Bubble Design - dynamic width based on content */
.chat-bubble { .chat-bubble {
display: inline-block; display: inline-block;

View File

@ -12,10 +12,16 @@
} }
.chat-line.assistant, .chat-line.assistant,
.chat-line.other { .chat-line.other,
.chat-line.tool {
justify-content: flex-start; justify-content: flex-start;
} }
.chat-line.tool .chat-bubble {
border-style: dashed;
opacity: 0.95;
}
.chat-msg { .chat-msg {
display: grid; display: grid;
gap: 6px; gap: 6px;

View File

@ -2,6 +2,22 @@
CHAT TEXT STYLING CHAT TEXT STYLING
============================================= */ ============================================= */
.chat-thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
:root[data-theme="light"] .chat-thinking {
border-color: rgba(16, 24, 40, 0.18);
background: rgba(16, 24, 40, 0.03);
}
.chat-text { .chat-text {
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;

View File

@ -92,6 +92,18 @@ export function renderChatControls(state: AppViewState) {
${refreshIcon} ${refreshIcon}
</button> </button>
<span class="chat-controls__separator">|</span> <span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${state.settings.chatShowThinking ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
})}
aria-pressed=${state.settings.chatShowThinking}
title="Toggle assistant thinking/working output"
>
🧠
</button>
<button <button
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}" class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
@click=${() => @click=${() =>

View File

@ -382,6 +382,7 @@ export function renderApp(state: AppViewState) {
void loadChatHistory(state); void loadChatHistory(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
showThinking: state.settings.chatShowThinking,
loading: state.chatLoading, loading: state.chatLoading,
sending: state.chatSending, sending: state.chatSending,
messages: state.chatMessages, messages: state.chatMessages,

View File

@ -3,7 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown"; import { toSanitizedMarkdownHtml } from "../markdown";
import type { MessageGroup } from "../types/chat-types"; import type { MessageGroup } from "../types/chat-types";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer"; import { classifyMessage } from "./message-classifier";
import { import {
extractText, extractText,
extractThinking, extractThinking,
@ -62,19 +62,25 @@ export function renderMessageGroup(
group: MessageGroup, group: MessageGroup,
opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean }, opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean },
) { ) {
const normalizedRole = normalizeRoleForGrouping(group.role); const roleKind = group.role;
const who = const who =
normalizedRole === "user" roleKind === "user"
? "You" ? "You"
: normalizedRole === "assistant" : roleKind === "assistant"
? "Assistant" ? "Assistant"
: normalizedRole; : roleKind === "tool"
? "Tool"
: roleKind === "system"
? "System"
: roleKind;
const roleClass = const roleClass =
normalizedRole === "user" roleKind === "user"
? "user" ? "user"
: normalizedRole === "assistant" : roleKind === "assistant"
? "assistant" ? "assistant"
: "other"; : roleKind === "tool"
? "tool"
: "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], { const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
@ -82,7 +88,7 @@ export function renderMessageGroup(
return html` return html`
<div class="chat-group ${roleClass}"> <div class="chat-group ${roleClass}">
${renderAvatar(group.role)} ${renderAvatar(roleKind)}
<div class="chat-group-messages"> <div class="chat-group-messages">
${group.messages.map((item, index) => ${group.messages.map((item, index) =>
renderGroupedMessage( renderGroupedMessage(
@ -105,9 +111,16 @@ export function renderMessageGroup(
} }
function renderAvatar(role: string) { function renderAvatar(role: string) {
const normalized = normalizeRoleForGrouping(role); const initial =
const initial = normalized === "user" ? "U" : normalized === "assistant" ? "A" : "?"; role === "user" ? "U" : role === "assistant" ? "A" : role === "tool" ? "⚙" : "?";
const className = normalized === "user" ? "user" : normalized === "assistant" ? "assistant" : "other"; const className =
role === "user"
? "user"
: role === "assistant"
? "assistant"
: role === "tool"
? "tool"
: "other";
return html`<div class="chat-avatar ${className}">${initial}</div>`; return html`<div class="chat-avatar ${className}">${initial}</div>`;
} }
@ -116,27 +129,27 @@ function renderGroupedMessage(
opts: { isStreaming: boolean; showReasoning: boolean }, opts: { isStreaming: boolean; showReasoning: boolean },
onOpenSidebar?: (content: string) => void, onOpenSidebar?: (content: string) => void,
) { ) {
const m = message as Record<string, unknown>; const classification = classifyMessage(message);
const role = typeof m.role === "string" ? m.role : "unknown"; const roleLower = classification.roleRaw.toLowerCase();
const isToolResult = const isToolResult =
isToolResultMessage(message) || classification.hasToolResults ||
role.toLowerCase() === "toolresult" || roleLower === "toolresult" ||
role.toLowerCase() === "tool_result" || roleLower === "tool_result" ||
typeof m.toolCallId === "string" || (classification.isToolLike && !classification.hasText);
typeof m.tool_call_id === "string";
const toolCards = extractToolCards(message); const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0; const hasToolCards = toolCards.length > 0;
const extractedText = extractText(message); const extractedText = extractText(message);
const extractedThinking = const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinking(message) : null; opts.showReasoning && classification.roleKind === "assistant"
? extractThinking(message)
: null;
const markdownBase = extractedText?.trim() ? extractedText : null; const markdownBase = extractedText?.trim() ? extractedText : null;
const markdown = extractedThinking const reasoningMarkdown = extractedThinking
? [formatReasoningMarkdown(extractedThinking), markdownBase] ? formatReasoningMarkdown(extractedThinking)
.filter(Boolean) : null;
.join("\n\n") const markdown = markdownBase;
: markdownBase;
const bubbleClasses = [ const bubbleClasses = [
"chat-bubble", "chat-bubble",
@ -156,6 +169,11 @@ function renderGroupedMessage(
return html` return html`
<div class="${bubbleClasses}"> <div class="${bubbleClasses}">
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown ${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing} : nothing}
@ -163,4 +181,3 @@ function renderGroupedMessage(
</div> </div>
`; `;
} }

View File

@ -2,10 +2,7 @@ import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown"; import { toSanitizedMarkdownHtml } from "../markdown";
import { import { classifyMessage } from "./message-classifier";
isToolResultMessage,
normalizeRoleForGrouping,
} from "./message-normalizer";
import { import {
extractText, extractText,
extractThinking, extractThinking,
@ -38,16 +35,19 @@ export function renderMessage(
opts?: { streaming?: boolean; showReasoning?: boolean }, opts?: { streaming?: boolean; showReasoning?: boolean },
) { ) {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown"; const classification = classifyMessage(message);
const toolCards = extractToolCards(message); const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0; const hasToolCards = toolCards.length > 0;
const isToolResult = const isToolResult =
isToolResultMessage(message) || classification.hasToolResults ||
typeof m.toolCallId === "string" || classification.roleRaw.toLowerCase() === "toolresult" ||
typeof m.tool_call_id === "string"; classification.roleRaw.toLowerCase() === "tool_result" ||
(classification.isToolLike && !classification.hasText);
const extractedText = extractText(message); const extractedText = extractText(message);
const extractedThinking = const extractedThinking =
opts?.showReasoning && role === "assistant" ? extractThinking(message) : null; opts?.showReasoning && classification.roleKind === "assistant"
? extractThinking(message)
: null;
const contentText = typeof m.content === "string" ? m.content : null; const contentText = typeof m.content === "string" ? m.content : null;
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2); const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
@ -64,28 +64,33 @@ export function renderMessage(
display?.kind === "json" display?.kind === "json"
? ["```json", display.value, "```"].join("\n") ? ["```json", display.value, "```"].join("\n")
: (display?.value ?? null); : (display?.value ?? null);
const markdown = extractedThinking const reasoningMarkdown = extractedThinking
? [formatReasoningMarkdown(extractedThinking), markdownBase] ? formatReasoningMarkdown(extractedThinking)
.filter(Boolean) : null;
.join("\n\n") const markdown = markdownBase;
: markdownBase;
const timestamp = const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
const normalizedRole = normalizeRoleForGrouping(role); const roleKind = classification.roleKind;
const klass = const klass =
normalizedRole === "assistant" roleKind === "assistant"
? "assistant" ? "assistant"
: normalizedRole === "user" : roleKind === "user"
? "user" ? "user"
: "other"; : roleKind === "tool"
? "tool"
: "other";
const who = const who =
normalizedRole === "assistant" roleKind === "assistant"
? "Assistant" ? "Assistant"
: normalizedRole === "user" : roleKind === "user"
? "You" ? "You"
: normalizedRole; : roleKind === "tool"
? "Tool"
: roleKind === "system"
? "System"
: classification.roleRaw;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
const toolCardBase = const toolCardBase =
@ -98,6 +103,11 @@ export function renderMessage(
<div class="chat-line ${klass}"> <div class="chat-line ${klass}">
<div class="chat-msg"> <div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}"> <div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown ${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing} : nothing}
@ -118,4 +128,3 @@ export function renderMessage(
</div> </div>
`; `;
} }

View File

@ -0,0 +1,101 @@
import { extractRawText, extractThinking } from "./message-extract";
export type RoleKind = "assistant" | "user" | "tool" | "system" | "other";
export type MessageClassification = {
roleRaw: string;
roleKind: RoleKind;
hasText: boolean;
hasThinking: boolean;
hasToolCalls: boolean;
hasToolResults: boolean;
isToolLike: boolean;
displayLabel: string;
};
const TOOL_CALL_TYPES = new Set([
"toolcall",
"tool_call",
"tooluse",
"tool_use",
"toolcall",
"tooluse",
"functioncall",
]);
const TOOL_RESULT_TYPES = new Set(["toolresult", "tool_result"]);
export function classifyMessage(message: unknown): MessageClassification {
const m = message as Record<string, unknown>;
const roleRaw = typeof m.role === "string" ? m.role : "unknown";
const roleLower = roleRaw.toLowerCase();
const content = Array.isArray(m.content)
? (m.content as Array<Record<string, unknown>>)
: [];
const hasText = Boolean(extractRawText(message)?.trim());
const hasThinking = Boolean(extractThinking(message)?.trim());
const hasToolCalls = content.some((item) => {
const kind = String(item.type ?? "").toLowerCase();
if (TOOL_CALL_TYPES.has(kind)) return true;
return typeof item.name === "string" && (item.arguments ?? item.args ?? item.input) != null;
});
const hasToolResults = content.some((item) =>
TOOL_RESULT_TYPES.has(String(item.type ?? "").toLowerCase()),
);
const hasToolId =
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string" ||
typeof m.toolUseId === "string" ||
typeof m.tool_use_id === "string";
const hasToolName =
typeof m.toolName === "string" || typeof m.tool_name === "string";
const isRoleTool =
roleLower === "tool" ||
roleLower === "toolresult" ||
roleLower === "tool_result" ||
roleLower === "function";
const isToolLike =
isRoleTool || hasToolId || hasToolName || hasToolCalls || hasToolResults;
let roleKind: RoleKind;
if (roleLower === "user") {
roleKind = "user";
} else if (roleLower === "assistant") {
roleKind = isToolLike && !hasText ? "tool" : "assistant";
} else if (roleLower === "system") {
roleKind = "system";
} else if (isRoleTool) {
roleKind = "tool";
} else if (isToolLike && !hasText) {
roleKind = "tool";
} else {
roleKind = "other";
}
const displayLabel =
roleKind === "assistant"
? "Assistant"
: roleKind === "user"
? "You"
: roleKind === "tool"
? "Tool"
: roleKind === "system"
? "System"
: roleRaw;
return {
roleRaw,
roleKind,
hasText,
hasThinking,
hasToolCalls,
hasToolResults,
isToolLike,
displayLabel,
};
}

View File

@ -1,9 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { import { normalizeMessage } from "./message-normalizer";
normalizeMessage, import { classifyMessage } from "./message-classifier";
normalizeRoleForGrouping,
isToolResultMessage,
} from "./message-normalizer";
describe("message-normalizer", () => { describe("message-normalizer", () => {
describe("normalizeMessage", () => { describe("normalizeMessage", () => {
@ -57,24 +54,24 @@ describe("message-normalizer", () => {
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]); expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
}); });
it("detects tool result by toolCallId", () => { it("preserves role when toolCallId is present", () => {
const result = normalizeMessage({ const result = normalizeMessage({
role: "assistant", role: "assistant",
toolCallId: "call-123", toolCallId: "call-123",
content: "Tool output", content: "Tool output",
}); });
expect(result.role).toBe("toolResult"); expect(result.role).toBe("assistant");
}); });
it("detects tool result by tool_call_id (snake_case)", () => { it("preserves role when tool_call_id is present", () => {
const result = normalizeMessage({ const result = normalizeMessage({
role: "assistant", role: "assistant",
tool_call_id: "call-456", tool_call_id: "call-456",
content: "Tool output", content: "Tool output",
}); });
expect(result.role).toBe("toolResult"); expect(result.role).toBe("assistant");
}); });
it("handles missing role", () => { it("handles missing role", () => {
@ -102,68 +99,54 @@ describe("message-normalizer", () => {
}); });
}); });
describe("normalizeRoleForGrouping", () => { describe("classifyMessage", () => {
it("returns assistant for toolresult", () => { it("keeps assistant role when text + tool blocks are mixed", () => {
expect(normalizeRoleForGrouping("toolresult")).toBe("assistant"); const result = classifyMessage({
expect(normalizeRoleForGrouping("toolResult")).toBe("assistant"); role: "assistant",
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("assistant"); content: [
{ type: "text", text: "before" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "thinking", thinking: "after" },
{ type: "text", text: "after text" },
],
});
expect(result.roleKind).toBe("assistant");
expect(result.hasText).toBe(true);
expect(result.hasToolCalls).toBe(true);
expect(result.hasThinking).toBe(true);
}); });
it("returns assistant for tool_result", () => { it("classifies tool-only assistant messages as tool", () => {
expect(normalizeRoleForGrouping("tool_result")).toBe("assistant"); const result = classifyMessage({
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("assistant"); role: "assistant",
toolCallId: "call-1",
content: [{ type: "toolCall", id: "call-1", name: "read" }],
});
expect(result.roleKind).toBe("tool");
expect(result.hasText).toBe(false);
expect(result.isToolLike).toBe(true);
}); });
it("returns assistant for tool", () => { it("classifies tool role messages as tool", () => {
expect(normalizeRoleForGrouping("tool")).toBe("assistant"); const result = classifyMessage({
expect(normalizeRoleForGrouping("Tool")).toBe("assistant"); role: "tool",
content: "Sunny, 70F.",
});
expect(result.roleKind).toBe("tool");
expect(result.hasText).toBe(true);
}); });
it("returns assistant for function", () => { it("classifies toolResult role messages as tool", () => {
expect(normalizeRoleForGrouping("function")).toBe("assistant"); const result = classifyMessage({
expect(normalizeRoleForGrouping("Function")).toBe("assistant"); role: "toolResult",
}); content: [{ type: "text", text: "ok" }],
});
it("preserves user role", () => { expect(result.roleKind).toBe("tool");
expect(normalizeRoleForGrouping("user")).toBe("user"); expect(result.isToolLike).toBe(true);
expect(normalizeRoleForGrouping("User")).toBe("User");
});
it("preserves assistant role", () => {
expect(normalizeRoleForGrouping("assistant")).toBe("assistant");
});
it("preserves system role", () => {
expect(normalizeRoleForGrouping("system")).toBe("system");
});
});
describe("isToolResultMessage", () => {
it("returns true for toolresult role", () => {
expect(isToolResultMessage({ role: "toolresult" })).toBe(true);
expect(isToolResultMessage({ role: "toolResult" })).toBe(true);
expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true);
});
it("returns true for tool_result role", () => {
expect(isToolResultMessage({ role: "tool_result" })).toBe(true);
expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true);
});
it("returns false for other roles", () => {
expect(isToolResultMessage({ role: "user" })).toBe(false);
expect(isToolResultMessage({ role: "assistant" })).toBe(false);
expect(isToolResultMessage({ role: "tool" })).toBe(false);
});
it("returns false for missing role", () => {
expect(isToolResultMessage({})).toBe(false);
expect(isToolResultMessage({ content: "test" })).toBe(false);
});
it("returns false for non-string role", () => {
expect(isToolResultMessage({ role: 123 })).toBe(false);
expect(isToolResultMessage({ role: null })).toBe(false);
}); });
}); });
}); });

View File

@ -12,12 +12,7 @@ import type {
*/ */
export function normalizeMessage(message: unknown): NormalizedMessage { export function normalizeMessage(message: unknown): NormalizedMessage {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
let role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
// Detect tool result messages by presence of toolCallId or tool_call_id
if (typeof m.toolCallId === "string" || typeof m.tool_call_id === "string") {
role = "toolResult";
}
// Extract content // Extract content
let content: MessageContentItem[] = []; let content: MessageContentItem[] = [];
@ -40,30 +35,3 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
return { role, content, timestamp, id }; return { role, content, timestamp, id };
} }
/**
* Normalize role for grouping purposes.
* Tool results should be grouped with assistant messages.
*/
export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase();
// All tool-related roles should display as assistant
if (
lower === "toolresult" ||
lower === "tool_result" ||
lower === "tool" ||
lower === "function"
) {
return "assistant";
}
return role;
}
/**
* Check if a message is a tool result message based on its role.
*/
export function isToolResultMessage(message: unknown): boolean {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
return role === "toolresult" || role === "tool_result";
}

View File

@ -9,7 +9,7 @@ import {
formatToolOutputForSidebar, formatToolOutputForSidebar,
getTruncatedPreview, getTruncatedPreview,
} from "./tool-helpers"; } from "./tool-helpers";
import { isToolResultMessage } from "./message-normalizer"; import { classifyMessage } from "./message-classifier";
import { extractText } from "./message-extract"; import { extractText } from "./message-extract";
export function extractToolCards(message: unknown): ToolCard[] { export function extractToolCards(message: unknown): ToolCard[] {
@ -39,10 +39,13 @@ export function extractToolCards(message: unknown): ToolCard[] {
cards.push({ kind: "result", name, text }); cards.push({ kind: "result", name, text });
} }
if ( const classification = classifyMessage(message);
isToolResultMessage(message) && const isToolResultMessage =
!cards.some((card) => card.kind === "result") classification.hasToolResults ||
) { classification.roleRaw.toLowerCase() === "toolresult" ||
classification.roleRaw.toLowerCase() === "tool_result";
if (isToolResultMessage && !cards.some((card) => card.kind === "result")) {
const name = const name =
(typeof m.toolName === "string" && m.toolName) || (typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) || (typeof m.tool_name === "string" && m.tool_name) ||
@ -197,4 +200,3 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.content === "string") return item.content; if (typeof item.content === "string") return item.content;
return undefined; return undefined;
} }

View File

@ -9,6 +9,7 @@ export type UiSettings = {
lastActiveSessionKey: string; lastActiveSessionKey: string;
theme: ThemeMode; theme: ThemeMode;
chatFocusMode: boolean; chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
useNewChatLayout: boolean; // Slack-style grouped messages layout useNewChatLayout: boolean; // Slack-style grouped messages layout
navCollapsed: boolean; // Collapsible sidebar state navCollapsed: boolean; // Collapsible sidebar state
@ -28,6 +29,7 @@ export function loadSettings(): UiSettings {
lastActiveSessionKey: "main", lastActiveSessionKey: "main",
theme: "system", theme: "system",
chatFocusMode: false, chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6, splitRatio: 0.6,
useNewChatLayout: true, // Enabled by default useNewChatLayout: true, // Enabled by default
navCollapsed: false, navCollapsed: false,
@ -65,6 +67,10 @@ export function loadSettings(): UiSettings {
typeof parsed.chatFocusMode === "boolean" typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode ? parsed.chatFocusMode
: defaults.chatFocusMode, : defaults.chatFocusMode,
chatShowThinking:
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
: defaults.chatShowThinking,
splitRatio: splitRatio:
typeof parsed.splitRatio === "number" && typeof parsed.splitRatio === "number" &&
parsed.splitRatio >= 0.4 && parsed.splitRatio >= 0.4 &&

View File

@ -3,10 +3,8 @@ import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types"; import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types"; import type { ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types"; import type { ChatItem, MessageGroup } from "../types/chat-types";
import { import { normalizeMessage } from "../chat/message-normalizer";
normalizeMessage, import { classifyMessage } from "../chat/message-classifier";
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { extractText } from "../chat/message-extract"; import { extractText } from "../chat/message-extract";
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render"; import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
import { import {
@ -21,6 +19,7 @@ export type ChatProps = {
sessionKey: string; sessionKey: string;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
thinkingLevel: string | null; thinkingLevel: string | null;
showThinking: boolean;
loading: boolean; loading: boolean;
sending: boolean; sending: boolean;
canAbort?: boolean; canAbort?: boolean;
@ -69,7 +68,7 @@ export function renderChat(props: ChatProps) {
(row) => row.key === props.sessionKey, (row) => row.key === props.sessionKey,
); );
const reasoningLevel = activeSession?.reasoningLevel ?? "off"; const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = reasoningLevel !== "off"; const showReasoning = props.showThinking && reasoningLevel !== "off";
const composePlaceholder = props.connected const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)" ? "Message (↩ to send, Shift+↩ for line breaks)"
@ -271,7 +270,8 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
} }
const normalized = normalizeMessage(item.message); const normalized = normalizeMessage(item.message);
const role = normalizeRoleForGrouping(normalized.role); const classification = classifyMessage(item.message);
const role = classification.roleKind;
const timestamp = normalized.timestamp || Date.now(); const timestamp = normalized.timestamp || Date.now();
if (!currentGroup || currentGroup.role !== role) { if (!currentGroup || currentGroup.role !== role) {
@ -310,10 +310,11 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
}); });
} }
for (let i = historyStart; i < history.length; i++) { for (let i = historyStart; i < history.length; i++) {
const msg = history[i];
items.push({ items.push({
kind: "message", kind: "message",
key: messageKey(history[i], i), key: messageKey(msg, i),
message: history[i], message: msg,
}); });
} }
for (let i = 0; i < tools.length; i++) { for (let i = 0; i < tools.length; i++) {