From 133124e0235cd7d331a6e941c5921a7975e4a5de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 05:24:11 +0000 Subject: [PATCH] fix: strip thinking tags in chat UI --- ui/src/ui/chat-markdown.browser.test.ts | 6 +++-- ui/src/ui/controllers/chat.ts | 15 ++++++++--- ui/src/ui/format.test.ts | 25 +++++++++++++++++ ui/src/ui/format.ts | 36 +++++++++++++++++++++++++ ui/src/ui/views/chat.ts | 15 ++++++++--- 5 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 ui/src/ui/format.test.ts diff --git a/ui/src/ui/chat-markdown.browser.test.ts b/ui/src/ui/chat-markdown.browser.test.ts index 5d8a3ff57..2cb835386 100644 --- a/ui/src/ui/chat-markdown.browser.test.ts +++ b/ui/src/ui/chat-markdown.browser.test.ts @@ -30,6 +30,7 @@ describe("chat markdown rendering", () => { const app = mountApp("/chat"); await app.updateComplete; + const timestamp = Date.now(); app.chatMessages = [ { role: "assistant", @@ -37,9 +38,11 @@ describe("chat markdown rendering", () => { { type: "toolcall", name: "noop", arguments: {} }, { type: "toolresult", name: "noop", text: "Hello **world**" }, ], - timestamp: Date.now(), + timestamp, }, ]; + // Expand the tool output card so its markdown is rendered into the DOM. + app.toolOutputExpanded = new Set([`${timestamp}:1`]); await app.updateComplete; @@ -47,4 +50,3 @@ describe("chat markdown rendering", () => { expect(strong?.textContent).toBe("world"); }); }); - diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 3d352fac0..2e52d9d47 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,4 +1,5 @@ import type { GatewayBrowserClient } from "../gateway"; +import { stripThinkingTags } from "../format"; import { generateUUID } from "../uuid"; export type ChatState = { @@ -127,8 +128,11 @@ export function handleChatEvent( function extractText(message: unknown): string | null { const m = message as Record; + const role = typeof m.role === "string" ? m.role : ""; const content = m.content; - if (typeof content === "string") return content; + if (typeof content === "string") { + return role === "assistant" ? stripThinkingTags(content) : content; + } if (Array.isArray(content)) { const parts = content .map((p) => { @@ -137,8 +141,13 @@ function extractText(message: unknown): string | null { return null; }) .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) return parts.join("\n"); + if (parts.length > 0) { + const joined = parts.join("\n"); + return role === "assistant" ? stripThinkingTags(joined) : joined; + } + } + if (typeof m.text === "string") { + return role === "assistant" ? stripThinkingTags(m.text) : m.text; } - if (typeof m.text === "string") return m.text; return null; } diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts new file mode 100644 index 000000000..dc785481c --- /dev/null +++ b/ui/src/ui/format.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { stripThinkingTags } from "./format"; + +describe("stripThinkingTags", () => { + it("strips segments", () => { + const input = ["", "secret", "", "", "Hello"].join("\n"); + expect(stripThinkingTags(input)).toBe("Hello"); + }); + + it("strips segments", () => { + const input = ["", "secret", "", "", "Hello"].join("\n"); + expect(stripThinkingTags(input)).toBe("Hello"); + }); + + it("keeps text when tags are unpaired", () => { + expect(stripThinkingTags("\nsecret\nHello")).toBe("secret\nHello"); + expect(stripThinkingTags("Hello\n")).toBe("Hello\n"); + }); + + it("returns original text when no tags exist", () => { + expect(stripThinkingTags("Hello")).toBe("Hello"); + }); +}); + diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index 26ebf7a8a..8e52af89a 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -66,3 +66,39 @@ export function parseList(input: string): string[] { .map((v) => v.trim()) .filter((v) => v.length > 0); } + +const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi; +const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i; +const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i; + +export function stripThinkingTags(value: string): string { + if (!value) return value; + const hasOpen = THINKING_OPEN_RE.test(value); + const hasClose = THINKING_CLOSE_RE.test(value); + if (!hasOpen && !hasClose) return value; + // If we don't have a balanced pair, avoid dropping trailing content. + if (hasOpen !== hasClose) { + if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart(); + return value.replace(THINKING_OPEN_RE, "").trimStart(); + } + + if (!THINKING_TAG_RE.test(value)) return value; + THINKING_TAG_RE.lastIndex = 0; + + let result = ""; + let lastIndex = 0; + let inThinking = false; + for (const match of value.matchAll(THINKING_TAG_RE)) { + const idx = match.index ?? 0; + if (!inThinking) { + result += value.slice(lastIndex, idx); + } + const tag = match[0].toLowerCase(); + inThinking = !tag.includes("/"); + lastIndex = idx + match[0].length; + } + if (!inThinking) { + result += value.slice(lastIndex); + } + return result.trimStart(); +} diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 213576f08..61ee55fa9 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { stripThinkingTags } from "../format"; import { toSanitizedMarkdownHtml } from "../markdown"; import { formatToolDetail, resolveToolDisplay } from "../tool-display"; import type { SessionsListResult } from "../types"; @@ -388,8 +389,11 @@ function renderMessage( function extractText(message: unknown): string | null { const m = message as Record; + const role = typeof m.role === "string" ? m.role : ""; const content = m.content; - if (typeof content === "string") return content; + if (typeof content === "string") { + return role === "assistant" ? stripThinkingTags(content) : content; + } if (Array.isArray(content)) { const parts = content .map((p) => { @@ -398,9 +402,14 @@ function extractText(message: unknown): string | null { return null; }) .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) return parts.join("\n"); + if (parts.length > 0) { + const joined = parts.join("\n"); + return role === "assistant" ? stripThinkingTags(joined) : joined; + } + } + if (typeof m.text === "string") { + return role === "assistant" ? stripThinkingTags(m.text) : m.text; } - if (typeof m.text === "string") return m.text; return null; }