prose tool_call{"ok":true}'} />,
+ );
+
+ expect(html).toContain("prose");
+ expect(html).toContain("Action");
+ expect(html).toContain("tool_call");
+ expect(html).toContain("Result");
+ expect(html).toContain("{"ok":true}");
+ });
+});
diff --git a/web/src/components/StructuredReasoning.tsx b/web/src/components/StructuredReasoning.tsx
new file mode 100644
index 0000000000..3ba6feea18
--- /dev/null
+++ b/web/src/components/StructuredReasoning.tsx
@@ -0,0 +1,58 @@
+import { useMemo } from "react";
+import { Markdown } from "@/components/Markdown";
+import {
+ parseReasoningMarkup,
+ type ReasoningMarkupSegment,
+} from "@/lib/reasoning-markup";
+
+export function StructuredReasoning({
+ content,
+ highlightTerms,
+}: {
+ content: string;
+ highlightTerms?: string[];
+}) {
+ const segments = useMemo(() => parseReasoningMarkup(content), [content]);
+
+ return (
+
+ {segments.map((segment, index) => (
+
+ ))}
+
+ );
+}
+
+function ReasoningSegment({
+ segment,
+ highlightTerms,
+}: {
+ segment: ReasoningMarkupSegment;
+ highlightTerms?: string[];
+}) {
+ if (segment.type === "prose") {
+ return (
+
+ );
+ }
+
+ const label = segment.type === "action" ? "Action" : "Result";
+
+ return (
+
+
+ {label}
+
+
+ {segment.content}
+
+
+ );
+}
diff --git a/web/src/lib/reasoning-markup.test.ts b/web/src/lib/reasoning-markup.test.ts
new file mode 100644
index 0000000000..70083f8399
--- /dev/null
+++ b/web/src/lib/reasoning-markup.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { hasReasoningMarkup, parseReasoningMarkup } from "./reasoning-markup";
+
+describe("reasoning markup parsing", () => {
+ it("unwraps thinking prose", () => {
+ expect(parseReasoningMarkup("plain prose")).toEqual([
+ { type: "prose", content: "plain prose" },
+ ]);
+ });
+
+ it("separates prose from action and result segments", () => {
+ expect(
+ parseReasoningMarkup(
+ 'prose tool_call{"ok":true}',
+ ),
+ ).toEqual([
+ { type: "prose", content: "prose " },
+ { type: "action", content: "tool_call" },
+ { type: "result", content: '{"ok":true}' },
+ ]);
+ });
+
+ it("does not treat unrelated angle-bracket text as reasoning markup", () => {
+ const content = "Compare and
in prose.";
+
+ expect(hasReasoningMarkup(content)).toBe(false);
+ expect(parseReasoningMarkup(content)).toEqual([
+ { type: "prose", content },
+ ]);
+ });
+
+ it("preserves fenced XML and HTML as ordinary markdown content", () => {
+ const content = [
+ "```xml",
+ "keep this literal",
+ "example
",
+ "```",
+ ].join("\n");
+
+ expect(hasReasoningMarkup(content)).toBe(false);
+ expect(parseReasoningMarkup(content)).toEqual([
+ { type: "prose", content },
+ ]);
+ });
+
+ it("leaves non-matching known tags as plain text", () => {
+ const content = "before unfinished";
+
+ expect(hasReasoningMarkup(content)).toBe(false);
+ expect(parseReasoningMarkup(content)).toEqual([
+ { type: "prose", content },
+ ]);
+ });
+});
diff --git a/web/src/lib/reasoning-markup.ts b/web/src/lib/reasoning-markup.ts
new file mode 100644
index 0000000000..14e9c42626
--- /dev/null
+++ b/web/src/lib/reasoning-markup.ts
@@ -0,0 +1,177 @@
+export type ReasoningMarkupSegment =
+ | { type: "prose"; content: string }
+ | { type: "action"; content: string }
+ | { type: "result"; content: string };
+
+type KnownTag = "thinking" | "reflection" | "action" | "result";
+
+const KNOWN_TAGS = new Set([
+ "thinking",
+ "reflection",
+ "action",
+ "result",
+]);
+const FENCE_RE = /^```/;
+const TAG_RE = /<\/?(thinking|reflection|action|result)>/gi;
+
+export function hasReasoningMarkup(content: string): boolean {
+ return splitFenceAware(content).some((chunk) => {
+ if (chunk.kind === "code") return false;
+ return findFirstCompleteKnownTag(chunk.content) !== null;
+ });
+}
+
+export function parseReasoningMarkup(content: string): ReasoningMarkupSegment[] {
+ return mergeAdjacentProse(
+ splitFenceAware(content).flatMap((chunk) =>
+ chunk.kind === "code"
+ ? [{ type: "prose" as const, content: chunk.content }]
+ : parseKnownTags(chunk.content),
+ ),
+ );
+}
+
+function parseKnownTags(content: string): ReasoningMarkupSegment[] {
+ const segments: ReasoningMarkupSegment[] = [];
+ let outputCursor = 0;
+ let searchCursor = 0;
+
+ for (;;) {
+ const opening = findNextOpeningTag(content, searchCursor);
+ if (!opening) break;
+
+ const closing = findMatchingCloseTag(content, opening.tag, opening.end);
+ if (!closing) {
+ searchCursor = opening.end;
+ continue;
+ }
+
+ if (opening.start > outputCursor) {
+ segments.push({
+ type: "prose",
+ content: content.slice(outputCursor, opening.start),
+ });
+ }
+
+ const inner = content.slice(opening.end, closing.start);
+ if (opening.tag === "action" || opening.tag === "result") {
+ segments.push({ type: opening.tag, content: inner.trim() });
+ } else {
+ segments.push(...parseKnownTags(inner));
+ }
+ outputCursor = closing.end;
+ searchCursor = closing.end;
+ }
+
+ if (outputCursor < content.length) {
+ segments.push({ type: "prose", content: content.slice(outputCursor) });
+ }
+
+ return segments.filter((segment) => segment.content.length > 0);
+}
+
+function findFirstCompleteKnownTag(content: string): KnownTag | null {
+ let cursor = 0;
+ for (;;) {
+ const opening = findNextOpeningTag(content, cursor);
+ if (!opening) return null;
+ if (findMatchingCloseTag(content, opening.tag, opening.end)) {
+ return opening.tag;
+ }
+ cursor = opening.end;
+ }
+}
+
+function findNextOpeningTag(
+ content: string,
+ from: number,
+): { tag: KnownTag; start: number; end: number } | null {
+ TAG_RE.lastIndex = from;
+ let match: RegExpExecArray | null;
+ while ((match = TAG_RE.exec(content)) !== null) {
+ const raw = match[0];
+ const tag = match[1].toLowerCase() as KnownTag;
+ if (!raw.startsWith("") && KNOWN_TAGS.has(tag)) {
+ return { tag, start: match.index, end: TAG_RE.lastIndex };
+ }
+ }
+ return null;
+}
+
+function findMatchingCloseTag(
+ content: string,
+ tag: KnownTag,
+ from: number,
+): { start: number; end: number } | null {
+ const tagRe = new RegExp(`?${tag}>`, "gi");
+ tagRe.lastIndex = from;
+ let depth = 1;
+ let match: RegExpExecArray | null;
+
+ while ((match = tagRe.exec(content)) !== null) {
+ if (match[0].startsWith("")) {
+ depth -= 1;
+ if (depth === 0) {
+ return { start: match.index, end: tagRe.lastIndex };
+ }
+ } else {
+ depth += 1;
+ }
+ }
+
+ return null;
+}
+
+function splitFenceAware(
+ content: string,
+): Array<{ kind: "prose" | "code"; content: string }> {
+ const chunks: Array<{ kind: "prose" | "code"; content: string }> = [];
+ const lines = content.split("\n");
+ let buffer: string[] = [];
+ let inCode = false;
+
+ for (const line of lines) {
+ if (FENCE_RE.test(line)) {
+ if (!inCode && buffer.length > 0) {
+ chunks.push({ kind: "prose", content: buffer.join("\n") });
+ buffer = [];
+ }
+ buffer.push(line);
+
+ if (inCode) {
+ chunks.push({ kind: "code", content: buffer.join("\n") });
+ buffer = [];
+ }
+ inCode = !inCode;
+ continue;
+ }
+
+ buffer.push(line);
+ }
+
+ if (buffer.length > 0) {
+ chunks.push({
+ kind: inCode ? "code" : "prose",
+ content: buffer.join("\n"),
+ });
+ }
+
+ return chunks;
+}
+
+function mergeAdjacentProse(
+ segments: ReasoningMarkupSegment[],
+): ReasoningMarkupSegment[] {
+ const merged: ReasoningMarkupSegment[] = [];
+
+ for (const segment of segments) {
+ const prev = merged.at(-1);
+ if (prev?.type === "prose" && segment.type === "prose") {
+ prev.content += `\n${segment.content}`;
+ } else {
+ merged.push({ ...segment });
+ }
+ }
+
+ return merged;
+}
diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx
index 0779086211..957e02cd9e 100644
--- a/web/src/pages/SessionsPage.tsx
+++ b/web/src/pages/SessionsPage.tsx
@@ -48,7 +48,9 @@ import type {
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
+import { StructuredReasoning } from "@/components/StructuredReasoning";
import { PlatformsCard } from "@/components/PlatformsCard";
+import { hasReasoningMarkup } from "@/lib/reasoning-markup";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
@@ -417,6 +419,11 @@ function MessageBubble({
{msg.content}
+ ) : hasReasoningMarkup(msg.content) ? (
+
) : (
))}