Stream Claude's thinking summaries inline in the conversation, with an ON/OFF toggle above the prompt.

See Claude's thinking summary stream inline in the conversation, before the completed block appears.
/plugin marketplace add AJclemendor/my-mods
/plugin install live-thinking@my-mods
/reload-plugins
The Live thinking: ON/OFF button above the prompt controls the preview. You can also use /live-thinking, /live-thinking on, or /live-thinking off. Each session starts ON. It works independently of compact-tools, and both plugins can be loaded together.
Install sidebar-controls to move the ON/OFF button into a top-right sidebar. Thinking remains inline in the conversation; closing the sidebar returns the button above the prompt.
The preview appears after the latest user message, assistant message, or tool result. Only the toggle occupies the area above the prompt. The preview clears when the response moves to text or a tool call, finishes, fails, or is cancelled. Claude's completed thinking block remains in the normal transcript. The preview shows the main conversation's thinking; it does not mix in background subagent streams.
This plugin displays the summary chunks Claude supplies. It cannot display thinking text that the provider omits or has not sent yet. Enable native thinking summaries by merging these keys into your existing ~/.claude/settings.json and restarting Claude:
{
"showThinkingSummaries": true,
"verbose": true
}
For a single session, use claude --verbose --thinking-display summarized. See the marketplace README if your Claude build has not enabled the mod runtime.
The plugin observes turn.step and passes every chunk and the final result through unchanged. It observes session.append to position the preview without changing stored messages. It does not make model requests, modify prompts, alter thinking signatures, or write thinking to files. It retains the latest 8,000 characters for the temporary preview; the normal response stream is not truncated.
The plugin targets Claude Code 2.1.287's terminal API. Its tests cover inline rendering, incremental updates before response completion, block transitions, toggling, subagent isolation, overlapping responses, errors, cancellation, bounded previews, and preserving the response stream. Offline terminal replays verified placement after user messages, assistant text, and real Read tool results in classic and fullscreen layouts, including alongside compact-tools. Run the runtime tests with:
claude plugin validate ./plugins/live-thinking --strict
claude plugin test ./plugins/live-thinkinghooks/live-thinking.mjs 164 lines1const enabledState = { plugin: "live-thinking", key: "enabled" };
2const previewState = { plugin: "live-thinking", key: "preview" };
3const anchorState = { plugin: "live-thinking", key: "anchor" };
4const MAX_PREVIEW_CHARS = 8000;
5
6export function register(on) {
7 on("session.start", async ($, e, next) => {
8 const result = await next(e);
9 await $.state.set(previewState, null);
10 await $.state.set(anchorState, null);
11 await $.command.register({
12 name: "live-thinking",
13 description: "Toggle live thinking inline in the conversation",
14 argumentHint: "[on|off]",
15 immediate: true,
16 });
17 return result;
18 });
19
20 on("session.append", async ($, e, next) => {
21 const result = await next(e);
22 if (e.agentId || result.deny) return result;
23 const message = result.message ?? e.message;
24 const blocks = message.content;
25 if (message.type === "user" || message.type === "assistant") {
26 const last = blocks.at(-1);
27 if (last?.type === "tool_result") {
28 await $.state.set(anchorState, { kind: "tool", id: last.tool_use_id });
29 } else if (last?.type === "tool_use") {
30 await $.state.set(anchorState, { kind: "tool", id: last.id });
31 } else if (last?.type === "text" && !message.isMeta) {
32 await $.state.set(anchorState, { kind: message.type, id: e.uuid });
33 }
34 }
35 return result;
36 });
37
38 on("command.run", { command: "live-thinking" }, async ($, e) => {
39 const arg = e.args.trim().toLowerCase();
40 if (arg && arg !== "on" && arg !== "off") return { text: "Usage: /live-thinking [on|off]" };
41 const { value = true } = await $.state.get(enabledState);
42 const enabled = arg ? arg === "on" : !value;
43 await $.state.set(enabledState, enabled);
44 return { text: `Live thinking ${enabled ? "on" : "off"}.` };
45 });
46
47 on("turn.step", async function* ($, e, next) {
48 if (e.agentId) return yield* next(e);
49 let version = null;
50 let text = "";
51 let block = null;
52
53 async function publish(value, first = false) {
54 if (!first && version === null) return;
55 try {
56 const written = await $.state.set(previewState, value, first ? undefined : { ifVersion: version });
57 version = written.isSet ? written.version : null;
58 } catch {
59 // A failed preview must not drop a response chunk or stop the model stream.
60 version = null;
61 }
62 }
63
64 await publish(null, true);
65 const stream = next(e);
66 let completed = false;
67 try {
68 while (true) {
69 const entry = await stream.next();
70 if (entry.done) {
71 completed = true;
72 return entry.value;
73 }
74 const chunk = entry.value;
75 if (chunk.kind === "thinking" && chunk.text) {
76 if (chunk.index !== block) {
77 text = "";
78 block = chunk.index;
79 }
80 text = previewTail(text + chunk.text);
81 await publish({ text });
82 } else if (text && (chunk.kind === "text" || chunk.kind === "tool" || chunk.kind === "stop")) {
83 text = "";
84 block = null;
85 await publish(null);
86 }
87 yield chunk;
88 }
89 } finally {
90 try {
91 if (!completed) await stream.return();
92 } finally {
93 // Only the stream that still owns this version can clear the preview.
94 await publish(null);
95 }
96 }
97 });
98
99 on("ui.render", { component: ["AbovePrompt", "Pane"] }, async ($, e, next) => {
100 const inSidebar = e.component === "Pane" && e.requestId === "sidebar-controls";
101 if (!inSidebar && e.component !== "AbovePrompt") return next(e);
102 const original = await next(e);
103 if (e.surface !== "terminal" || e.props.hasSurvey || e.props.view?.agentId) return original;
104 if (!inSidebar) {
105 const { value: sidebarVisible } = await $.state.get({ plugin: "sidebar-controls", key: "visible" });
106 if (sidebarVisible) return original;
107 }
108 const { value: enabled = true } = await $.state.get(enabledState);
109 const { Box, Button } = $.ui.resolve(e);
110 const children = [original, Button({
111 key: "toggle-live-thinking",
112 label: `Live thinking: ${enabled ? "ON" : "OFF"}`,
113 color: enabled ? "cyan" : "gray",
114 onPress: async () => {
115 const { value = true } = await $.state.get(enabledState);
116 await $.state.set(enabledState, !value);
117 },
118 })];
119 return Box({ flexDirection: "column", children });
120 });
121
122 on("ui.render", async ($, e, next) => {
123 if (e.surface !== "terminal" || !["UserMessage", "AssistantMessage", "ToolResult", "ToolGroup"].includes(e.component)) {
124 return next(e);
125 }
126 const { value: anchor } = await $.state.get(anchorState);
127 if (!isAnchor(e, anchor)) return next(e);
128 const original = await next(e);
129 const { value: enabled = true } = await $.state.get(enabledState);
130 const { value: preview } = await $.state.get(previewState);
131 if (!enabled || !preview?.text) return original;
132 const visible = previewTail(preview.text
133 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
134 .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "")
135 .replace(/\t/g, " "));
136 const { Box, Text } = $.ui.resolve(e);
137 return Box({
138 flexDirection: "column",
139 children: [original, Box({
140 marginTop: 1,
141 flexDirection: "row",
142 children: [Text({ dimColor: true, children: "∴ " }), Box({
143 key: "thinking-preview",
144 flexShrink: 1,
145 children: [Text({ dimColor: true, children: visible })],
146 })],
147 })],
148 });
149 });
150}
151
152function isAnchor(e, anchor) {
153 if (!anchor) return false;
154 if (anchor.kind === "user") return e.component === "UserMessage" && e.requestId === anchor.id;
155 if (anchor.kind === "assistant") return e.component === "AssistantMessage" && e.requestId === anchor.id;
156 if (e.component === "ToolResult") return e.props.tool_use_id === anchor.id;
157 return e.component === "ToolGroup" && e.props.calls.some(call => call.tool_use_id === anchor.id);
158}
159
160function previewTail(text) {
161 const tail = text.slice(-MAX_PREVIEW_CHARS);
162 return /^[\uDC00-\uDFFF]/.test(tail) ? tail.slice(1) : tail;
163}
164types/index.d.ts 11 lines1declare module "claude-code" {
2 interface PluginState {
3 "sidebar-controls": { visible: boolean };
4 "live-thinking": {
5 enabled: boolean;
6 preview: { text: string } | null;
7 anchor: { kind: "user" | "assistant" | "tool"; id: string } | null;
8 };
9 }
10}
11