Shows Claude's thinking live in a side panel next to the chat, with optional translation

A Claude Code mod that shows Claude's thinking live in a side panel next to the chat, instead of mixed into the conversation.
The panel UI labels are in Spanish (the panel is titled "Razonamiento").
At the prompt of a Claude Code terminal session:
/plugin install razonamiento --marketplace gomez5757/claude-thinking-panel
From a shell:
claude plugin install razonamiento --marketplace gomez5757/claude-thinking-panel
In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with gomez5757/claude-thinking-panel, then Add plugin.
Install it at the user scope to get the panel in every session. Sessions already open pick it up after a restart.
The panel opens by itself when a session starts. If you close it, run /razonamiento to open it again.
The panel only receives the thinking that Claude Code is set to show. In the desktop app, set the conversation's transcript view to "thinking" (title bar menu, or Ctrl+O). With the "normal" view the panel stays empty.
Two options in the plugin's config:
translateTo: off, Spanish, English, French, German, Portuguese, Italian, Chinese or Japanese. Default off.translator:haiku (default): Claude Haiku at low effort, through your own Claude session. Good quality, uses a little of your usage per block.google-free: Google Translate's free web endpoint (translate.googleapis.com, client=gtx). No key, no account, no usage. It is unofficial: Google can rate-limit or stop it at any time, and your thinking text is sent to Google.While a block is being written it shows as written; once the block ends it is replaced by the translation, usually within a second or two.
Or set them in ~/.claude/settings.json:
"pluginConfigs": {
"razonamiento@claude-thinking-panel": {
"options": { "translateTo": "Spanish", "translator": "google-free" }
}
}
The mod hooks the model's response stream (turn.step), copies the thinking chunks into session state and draws them in a pane. It reads nothing from disk and sends nothing anywhere, except the optional translation request (to your own Claude session, or to Google with google-free).
MIT
hooks/register.tsx 184 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Block } from '../types'
5
6const PANE = 'razonamiento'
7const CODES: Record<string, string> = {
8 Spanish: 'es',
9 English: 'en',
10 French: 'fr',
11 German: 'de',
12 Portuguese: 'pt',
13 Italian: 'it',
14 Chinese: 'zh-CN',
15 Japanese: 'ja',
16}
17async function translate($: EngineInterface, translator: string, lang: string, text: string) {
18 if (translator === 'google-free') {
19 const r = await $.http.fetch(
20 `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${CODES[lang] ?? 'es'}&dt=t`,
21 {
22 method: 'POST',
23 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
24 body: `q=${encodeURIComponent(text)}`,
25 },
26 )
27 if (!r.ok) return undefined
28 const parts: [string][] = JSON.parse(r.text)[0] ?? []
29 return parts.map(part => part[0]).join('')
30 }
31 const r = await $.model.complete({
32 model: 'haiku',
33 effort: 'low',
34 system: `Translate the text you are given into ${lang}. Return only the translation, no comments. Do not translate code, paths or tool names.`,
35 prompt: text,
36 })
37 return r.isAnswered ? r.text : undefined
38}
39
40const WEEK = 7 * 24 * 60 * 60 * 1000
41
42// The pane's title carries the context in use: the last response's real input tokens against the window
43// auto-compaction fires at (the app's Context panel measures against it too, but with estimated tokens).
44// Then the weekly limit projected to its reset at the current pace.
45async function title($: EngineInterface) {
46 const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
47 const parts = ['Razonamiento']
48 const max = context.breakdown?.rawMaxTokens || context.window
49 if (context.tokens !== undefined && max) parts.push(`${Math.round((context.tokens / max) * 100)}% contexto`)
50 const week = rateLimits.find(limit => limit.kind === 'seven_day')
51 if (week?.resetsAt) {
52 const elapsed = WEEK - (Date.parse(week.resetsAt) - Date.now())
53 if (elapsed > 60 * 60 * 1000) parts.push(`semana ~${Math.round((week.percentUsed * WEEK) / elapsed)}% al reinicio`)
54 }
55 return parts.join(' - ')
56}
57
58// Retitle the pane in place, without reopening it if the person closed it.
59async function retitle($: EngineInterface) {
60 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
61 await $.ui.open({ id: PANE, title: await title($) })
62}
63
64const blocks = atom({ plugin: 'razonamiento', key: 'blocks' } as const, [])
65
66export const register: Register = (on, options) => {
67 const lang = typeof options.translateTo === 'string' ? options.translateTo : 'off'
68 const translator = options.translator === 'google-free' ? 'google-free' : 'haiku'
69 let lastPin = 0
70
71 on('session.start', async ($, e, next) => {
72 await $.command.register({
73 name: 'razonamiento',
74 description: 'Abre el panel con el razonamiento de Claude',
75 })
76 void title($).then(t => $.ui.open({ id: PANE, title: t }))
77
78 return next(e)
79 })
80
81 on('command.run', { command: 'razonamiento' }, async $ => {
82 await $.ui.open({ id: PANE, title: await title($) })
83
84 return { text: 'Panel de razonamiento abierto.' }
85 })
86
87 on('prompt.submit', async ($, e, next) => {
88 const prompt: Block = { id: `p-${Date.now()}`, who: 'tu', text: e.text, isDone: true, isPrompt: true }
89 await update($, blocks, list => [...list, prompt].slice(-50))
90 void $.ui.scroll({ in: PANE, to: 'end' })
91
92 return next(e)
93 }).catch(($, e, next) => next(e))
94
95 on('turn.step', async function* ($, e, next) {
96 const who = e.agentId ? 'subagente' : 'Claude'
97 const texts = new Map<number, string>()
98 let lastFlush = 0
99
100 const flush = async (isDone: boolean) => {
101 lastFlush = Date.now()
102 const fresh: Block[] = [...texts].map(([index, text]) => ({
103 id: `${e.turnId}-${e.index}-${index}`,
104 who,
105 text,
106 isDone,
107 }))
108 await update($, blocks, list => {
109 const ids = new Set(fresh.map(b => b.id))
110 return [...list.filter(b => !ids.has(b.id)), ...fresh].slice(-50)
111 })
112 void $.ui.scroll({ in: PANE, to: 'end' })
113 }
114
115 const stream = next(e)
116 for await (const chunk of stream) {
117 if (chunk.kind === 'thinking') {
118 texts.set(chunk.index, (texts.get(chunk.index) ?? '') + chunk.text)
119 if (Date.now() - lastFlush > 300) await flush(false)
120 }
121 yield chunk
122 }
123 if (texts.size > 0) {
124 await flush(true)
125 if (lang !== 'off') for (const [index, text] of texts) {
126 const id = `${e.turnId}-${e.index}-${index}`
127 void translate($, translator, lang, text)
128 .catch(() => undefined)
129 .then(async es => {
130 if (!es) return
131 await update($, blocks, list => list.map(b => (b.id === id ? { ...b, es } : b)))
132 void $.ui.scroll({ in: PANE, to: 'end' })
133 })
134 }
135 }
136
137 const result = await stream.result
138 if (!e.agentId) void retitle($).catch(() => undefined)
139
140 return result
141 })
142
143 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144 const { Box, Text } = $.ui.resolve(e)
145 const all = await read($, blocks)
146 // A pane drawn afresh (another chat opened, the app restarted) starts at the top: pin it to the end once drawn.
147 if (Date.now() - lastPin > 2000) {
148 lastPin = Date.now()
149 void $.clock.sleep(150).then(() => $.ui.scroll({ in: PANE, to: 'end' }))
150 }
151 // The pane draws at most 100,000 characters, cut from the end, so keep the newest that fit.
152 let room = 80000
153 let start = all.length
154 while (start > 0) {
155 const b = all[start - 1]
156 room -= (b.es ?? b.text).length
157 if (room < 0 && start < all.length) break
158 start--
159 }
160 const list = all.slice(start)
161
162 return (
163 <Box flexDirection="column">
164 {list.length === 0 && <Text dimColor>Sin razonamiento todavia.</Text>}
165 {list.map(b =>
166 b.isPrompt ? (
167 <Box flexDirection="row" justifyContent="flex-end" marginTop={1} marginLeft={6}>
168 <Box borderStyle="round" paddingX={1} flexShrink={1}>
169 <Text>{b.text.length > 400 ? `${b.text.slice(0, 400)}...` : b.text}</Text>
170 </Box>
171 </Box>
172 ) : (
173 <Box flexDirection="column" marginTop={1}>
174 {b.who !== 'Claude' && <Text bold>{b.who}</Text>}
175 {!b.isDone && <Text bold>pensando...</Text>}
176 <Text>{b.es ?? b.text}</Text>
177 </Box>
178 ),
179 )}
180 </Box>
181 )
182 })
183}
184types/index.d.ts 8 lines1export type Block = { id: string; who: string; text: string; isDone: boolean; es?: string; isPrompt?: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 razonamiento: { blocks: Block[] }
6 }
7}
8