diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index 3cbd6c1..eb3641c 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -178,6 +178,29 @@ export type SessionStatus =
| "waiting_approval"
| "error";
+/**
+ * Placeholder body for a `thinking` message the backend produced no readable
+ * reasoning for.
+ *
+ * A thinking message must be committed with NON-EMPTY content — the TUI's
+ * "same msgId + content ⇒ committed" rule is what retires the live spinner, so
+ * an empty commit leaves `Thinking…` stuck. Hence a sentinel rather than "".
+ *
+ * Shared (not duplicated) because both sides key off it: the daemon writes it,
+ * and clients use it to tell "reasoning exists, show an expander" apart from
+ * "there is nothing behind this" — an expander that opens onto a placeholder is
+ * worse than no expander at all.
+ *
+ * Whether reasoning arrives at all is a BACKEND property, not a codeoid one.
+ * Claude returns readable text only when `thinking.display` is `"summarized"`
+ * (the API default is `"omitted"`, which streams thinking blocks with empty
+ * text); the raw chain of thought is never exposed on any model. Other
+ * backends — qwen-code, and OSS models behind an OpenAI-compatible gateway —
+ * stream plaintext reasoning directly. So both branches are live at once, and
+ * neither is an error state.
+ */
+export const REASONING_UNAVAILABLE = "(no reasoning returned by this model)";
+
/** True when the session is mid-turn (either reasoning or running a tool). */
export function isActiveStatus(s: SessionStatus): boolean {
return s === "thinking" || s === "tool_running";
diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts
index 143205d..abcf254 100644
--- a/src/daemon/providers/claude/index.ts
+++ b/src/daemon/providers/claude/index.ts
@@ -543,7 +543,16 @@ export class ClaudeProvider implements SessionProvider {
permissionMode: "default",
includePartialMessages: true,
persistSession: true,
- thinking: { type: "adaptive" as const },
+ // `display` defaults to "omitted" on Opus 5 / 4.8 / 4.7, Sonnet 5, and
+ // Fable 5 — which streams thinking blocks whose text is EMPTY. Codeoid
+ // then committed them as a placeholder, so every reasoning block in the
+ // UI expanded onto nothing (measured: 3,445 of 3,450 on one session).
+ // "summarized" returns readable reasoning instead. It is free: display
+ // controls visibility only, and thinking is billed identically either
+ // way — the tokens were already spent, we just weren't being shown
+ // anything for them. (The raw chain of thought is never exposed on any
+ // model; this is a summary of it.)
+ thinking: { type: "adaptive" as const, display: "summarized" as const },
...(opts.model ? { model: opts.model } : {}),
...(opts.fallbackModel ? { fallbackModel: opts.fallbackModel } : {}),
stderr: (data: string) => {
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index de65a6a..8415603 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -61,7 +61,13 @@ import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** Max wall-clock for a fork.setup command (deps install can be slow). */
const FORK_SETUP_TIMEOUT_MS = 600_000;
-import { authToIdentity, CAPABILITIES, isActiveStatus, SYSTEM_IDENTITY } from "../protocol/types.js";
+import {
+ authToIdentity,
+ CAPABILITIES,
+ isActiveStatus,
+ REASONING_UNAVAILABLE,
+ SYSTEM_IDENTITY,
+} from "../protocol/types.js";
import type { Store } from "./store.js";
import type { AgentIdentityManager } from "./agent-identity.js";
import { ScrollbackBuffer } from "./scrollback.js";
@@ -4290,7 +4296,11 @@ export class Session {
this.#activeThinkingMsg = null;
this.#activeThinkingIndex = null;
if (!m.content || m.content.length === 0) {
- m.content = "(reasoning elided)";
+ // Not an elision by codeoid — the backend returned no readable reasoning.
+ // Claude does this whenever `thinking.display` is `"omitted"`; backends
+ // that stream plaintext reasoning (qwen-code, OSS models over an
+ // OpenAI-compatible gateway) land in the branch above with real content.
+ m.content = REASONING_UNAVAILABLE;
}
this.#commitStreamed(m);
this.#broadcastRaw(m);
diff --git a/web/src/components/transcript/MessageRow.test.tsx b/web/src/components/transcript/MessageRow.test.tsx
index f30fe95..420b003 100644
--- a/web/src/components/transcript/MessageRow.test.tsx
+++ b/web/src/components/transcript/MessageRow.test.tsx
@@ -4,6 +4,7 @@ import { render, cleanup } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import MessageRow from "./MessageRow";
+import { REASONING_UNAVAILABLE } from "../../protocol/types";
import type { SessionMessage } from "../../protocol/types";
function thinkingMsg(content: string): SessionMessage {
@@ -29,7 +30,34 @@ describe("ThinkingBlock", () => {
expect(three.container.textContent).toContain("reasoning (3 lines)");
cleanup();
const one = render(() => );
- expect(one.container.textContent).toContain("reasoning (1 lines)");
+ expect(one.container.textContent).toContain("reasoning (1 line)");
+ });
+
+ it("renders a flat marker — not an expander — when no reasoning was returned", () => {
+ // Backends differ: qwen-code and OSS models stream plaintext reasoning,
+ // while Claude returns text only under thinking.display "summarized".
+ // With nothing behind it, an expander advertises content it can't show.
+ const { container } = render(() => );
+ expect(container.querySelector("details")).toBeNull();
+ expect(container.textContent).toContain(REASONING_UNAVAILABLE);
+ // No line count either — "(1 line)" over a placeholder is what made the
+ // original report look like a counting bug rather than absent data.
+ expect(container.textContent).not.toContain("line)");
+ });
+
+ it("renders an expander when the backend DID return reasoning", () => {
+ const { container } = render(() =>
+ ,
+ );
+ const details = container.querySelector("details");
+ expect(details).not.toBeNull();
+ expect(container.textContent).toContain("reasoning (2 lines)");
+ expect(container.textContent).toContain("Considering the tradeoffs");
+ });
+
+ it("treats whitespace-only reasoning as absent", () => {
+ const { container } = render(() => );
+ expect(container.querySelector("details")).toBeNull();
});
it("coalesces streaming deltas to one recount per animation frame", () => {
@@ -62,7 +90,7 @@ describe("ThinkingBlock", () => {
it("updates synchronously when not streaming (throttle passthrough)", () => {
const [msg, setMsg] = createSignal(thinkingMsg("x"));
const { container } = render(() => );
- expect(container.textContent).toContain("reasoning (1 lines)");
+ expect(container.textContent).toContain("reasoning (1 line)");
setMsg(thinkingMsg("x\ny\nz"));
expect(container.textContent).toContain("reasoning (3 lines)");
expect(container.textContent).toContain("x\ny\nz");
diff --git a/web/src/components/transcript/MessageRow.tsx b/web/src/components/transcript/MessageRow.tsx
index e531508..a96ce73 100644
--- a/web/src/components/transcript/MessageRow.tsx
+++ b/web/src/components/transcript/MessageRow.tsx
@@ -19,6 +19,7 @@ import {
createFrameThrottled,
splitStreamingBlocks,
} from "../../lib/streaming-markdown";
+import { REASONING_UNAVAILABLE } from "../../protocol/types";
import type {
MessageRole,
SessionMessage,
@@ -208,17 +209,41 @@ const ThinkingBlock: Component<{ text: string; streaming?: boolean }> = (props)
() => props.text,
() => props.streaming === true,
);
+ /**
+ * Whether there is real reasoning behind this block.
+ *
+ * Backends differ and both branches are normal: qwen-code and OSS models over
+ * an OpenAI-compatible gateway stream plaintext reasoning, while Claude
+ * returns readable text only under `thinking.display: "summarized"`. When
+ * there is nothing, an expander is actively misleading — it advertises
+ * content, counts "1 lines", and opens onto a placeholder. Render a flat
+ * marker instead, so the thinking→answer rhythm still reads without
+ * promising something that can't be shown.
+ */
+ const hasReasoning = createMemo(() => {
+ const t = throttled().trim();
+ return t.length > 0 && t !== REASONING_UNAVAILABLE;
+ });
const lineCount = createMemo(() => countLines(throttled()));
return (
-
-
- reasoning ({lineCount()} lines)
-
-
-
-
- {throttled()}
-
+
+ {REASONING_UNAVAILABLE}
+
+ }
+ >
+
+
+ reasoning ({lineCount()} {lineCount() === 1 ? "line" : "lines"})
+
+
+
+
+ {throttled()}
+
+
);
};