SLOPSHOPPER

razonamiento

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

newpanecommandpromptmodelnetwork
v0.1.0MITupdated 2026-10-26gomez5757/claude-thinking-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · razonamiento
│ ┃ Razonamiento - 49% contexto ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ fix the failing auth test and add │ ⎿ Read 6 lines │ ┃ │ an audit log call │ ⏺ Update(src/auth.ts) │ ┃ ╰────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /razonamiento │ ⎿ razonamiento: Panel de razonamiento abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Razonamiento - 49% contexto
╭────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log │ │ call │ ╰────────────────────────────────────────────────────╯
README

Claude Thinking Panel

A Claude Code mod that shows Claude's thinking live in a side panel next to the chat, instead of mixed into the conversation.

  • Each of your prompts appears as a bubble on the right, with the thinking it triggered underneath, just like the chat.
  • Thinking streams in as Claude writes it ("pensando..." while a block is still being written) and the panel scrolls to the newest text.
  • Optional translation: each finished thinking block can be translated into the language you pick, either by Claude Haiku (low effort) or for free by Google Translate's web endpoint. Off by default.
  • Subagent thinking is shown too, labelled "subagente".

The panel UI labels are in Spanish (the panel is titled "Razonamiento").

Install

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.

Use

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.

Translation

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" }
  }
}

How it works

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).

License

MIT

Source 2 files
hooks/register.tsx 184 lines
1import { 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}
184
types/index.d.ts 8 lines
1export 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