A pane of Claude's dry inner thoughts about your session, typed out live

A pane of Claude's dry inner thoughts about your session, typed out live.

While the pane is open, a model call every so often turns the recent prompts and tool calls into one dry line, typed out live. Harmless and weirdly useful for noticing when the agent is flailing.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install inner-monologue@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./inner-monologue
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| inner-monologue | No | No | No | Yes, a summary of recent tool calls and the first 120 characters of each prompt | Only to your Claude model, and only while the pane is open |
Run claude plugin validate ./inner-monologue to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 157 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Thought } from '../types'
5import { DEMO, Inbox, THINKER, describeCall, isDue, revealed, thinkerPrompt, thoughtLine } from './logic'
6
7const PANE = 'inner-monologue'
8const KEEP = 30
9const GOLD = '#c2a87e'
10const BLUE = '#61A5FA'
11const PAPER = '#E8E2D6'
12const STONE = '#8A8478'
13
14const thoughts = atom({ plugin: 'inner-monologue', key: 'thoughts' } as const, [] as Thought[])
15const isThinking = atom({ plugin: 'inner-monologue', key: 'isThinking' } as const, false)
16
17const inbox = new Inbox()
18let lastCallAt = 0
19let isBusy = false
20let isDemo = false
21let typing: { cancel: () => void } | null = null
22
23const clock = (at: number) => {
24 const d = new Date(at)
25 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
26}
27
28const isOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
29
30/** Redraws at ~30/s while the newest thought is still being typed, then stops. */
31const type = ($: EngineInterface) => {
32 if (typing) return
33 const timer = $.clock.every(34, () => {
34 void (async () => {
35 const newest = (await read($, thoughts)).at(-1)
36 const now = await $.clock.now()
37 $.ui.invalidate('ui.render')
38 if (!newest || revealed(newest.text, now - newest.at) >= newest.text.length) {
39 timer.cancel()
40 if (typing === timer) typing = null
41 }
42 })()
43 })
44 typing = timer
45}
46
47const think = async ($: EngineInterface, text: string) => {
48 const at = await $.clock.now()
49 await update($, thoughts, list => [...list, { id: at, text, at }].slice(-KEEP))
50 type($)
51}
52
53const tick = async ($: EngineInterface) => {
54 if (isBusy || isDemo || inbox.size === 0) return
55 const now = await $.clock.now()
56 if (!isDue(now, lastCallAt) || !(await isOpen($))) return
57 isBusy = true
58 lastCallAt = now
59 await update($, isThinking, () => true)
60 try {
61 const earlier = (await read($, thoughts)).slice(-4).map(t => t.text)
62 const reply = await $.model.complete({
63 model: 'haiku',
64 system: THINKER,
65 prompt: thinkerPrompt(inbox.drain(), earlier),
66 maxTokens: 80,
67 effort: 'low',
68 })
69 const line = reply.isAnswered ? thoughtLine(reply.text) : ''
70 if (line) await think($, line)
71 } finally {
72 isBusy = false
73 await update($, isThinking, () => false)
74 }
75}
76
77const runDemo = async ($: EngineInterface) => {
78 isDemo = true
79 await update($, thoughts, () => [])
80 DEMO.forEach((text, i) => {
81 $.clock.after(i * 3300, () => {
82 void think($, text).then(() => {
83 if (i === DEMO.length - 1) isDemo = false
84 })
85 })
86 })
87}
88
89export const register: Register = on => {
90 on('session.start', async ($, e, next) => {
91 await $.command.register({
92 name: 'monologue',
93 description: "Claude's inner monologue pane: /monologue [demo|clear]",
94 })
95 $.clock.every(500, () => void tick($).catch(() => undefined))
96
97 return next(e)
98 })
99
100 on('command.run', { command: 'monologue' }, async ($, e) => {
101 const arg = e.args.trim().toLowerCase()
102 await $.ui.open({ id: PANE, title: 'Inner monologue' })
103 if (arg === 'demo') {
104 await runDemo($)
105 return { text: 'Inner monologue: demo playing.' }
106 }
107 if (arg === 'clear') {
108 await update($, thoughts, () => [])
109 return { text: 'Inner monologue cleared.' }
110 }
111 return { text: 'Inner monologue open. It thinks while the pane is open.' }
112 })
113
114 on('prompt.submit', ($, e, next) => {
115 if (e.text.trim() && !e.text.trim().startsWith('/')) inbox.push(`the user asks: "${e.text.trim().slice(0, 120)}"`)
116 return next(e)
117 })
118
119 on('tool.call', ($, e, next) => {
120 inbox.push(`${e.agentId ? 'a subagent is ' : ''}${describeCall(String(e.tool), e as unknown as Record<string, unknown>)}`)
121 return next(e)
122 })
123
124 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
125 const { Box, Text } = $.ui.resolve(e)
126 const list = await read($, thoughts)
127 const busy = await read($, isThinking)
128 const now = await $.clock.now()
129 const room = Math.max(2, Math.floor(((e.viewport?.rows ?? 24) - 4) / 2))
130 const shown = list.slice(-room).reverse()
131
132 return (
133 <Box flexDirection="column" paddingX={1}>
134 <Box justifyContent="space-between" marginBottom={1}>
135 <Text color={GOLD} bold>INNER MONOLOGUE</Text>
136 <Text color={STONE}>{busy ? 'thinking...' : isDemo ? 'demo' : `${list.length} thoughts`}</Text>
137 </Box>
138 {shown.length === 0 && <Text color={STONE}>Quiet in here. Give Claude something to do.</Text>}
139 {shown.map((t, i) => {
140 const isNewest = i === 0
141 const count = isNewest ? revealed(t.text, now - t.at) : t.text.length
142 const isTyping = isNewest && count < t.text.length
143 return (
144 <Box key={`t-${t.id}`} marginBottom={1}>
145 <Text color={isNewest ? GOLD : STONE}>{clock(t.at)} </Text>
146 <Text wrap="wrap" color={isNewest ? PAPER : STONE} dimColor={i > 3}>
147 {t.text.slice(0, count)}
148 {isTyping && <Text color={BLUE}>▌</Text>}
149 </Text>
150 </Box>
151 )
152 })}
153 </Box>
154 )
155 })
156}
157hooks/logic.ts 109 lines1/** Pure inner-monologue logic: events to notes, coalescing, the voice, and the typewriter. */
2
3const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
4
5const clip = (text: string, chars: number) => {
6 const one = text.replace(/\s+/g, ' ').trim()
7 return one.length > chars ? `${one.slice(0, chars - 3)}...` : one
8}
9
10/** One line about a tool call, for the thinker's prompt. */
11export const describeCall = (tool: string, input: Record<string, unknown>) => {
12 const file = typeof input.file_path === 'string' ? base(input.file_path) : ''
13 switch (tool) {
14 case 'Read':
15 return `reading ${file || 'a file'}`
16 case 'Grep':
17 return `grepping for "${clip(String(input.pattern ?? ''), 40)}"`
18 case 'Glob':
19 return `globbing ${clip(String(input.pattern ?? ''), 40)}`
20 case 'Edit':
21 case 'MultiEdit':
22 return `editing ${file || 'a file'}`
23 case 'Write':
24 return `writing ${file || 'a new file'} from scratch`
25 case 'Bash':
26 return `running \`${clip(String(input.command ?? ''), 60)}\``
27 case 'Agent':
28 case 'Task':
29 return `delegating to a subagent: ${clip(String(input.description ?? ''), 40)}`
30 case 'WebSearch':
31 return `searching the web for "${clip(String(input.query ?? ''), 40)}"`
32 case 'WebFetch':
33 return 'fetching a web page'
34 case 'TodoWrite':
35 return 'updating the todo list'
36 default:
37 return tool.startsWith('mcp__') ? `calling ${tool.split('__').slice(1).join(' ')}` : `using ${tool}`
38 }
39}
40
41/** Notes waiting for the next thought: repeats fold into "x3", oldest drop past the cap. */
42export class Inbox {
43 private notes: { text: string; count: number }[] = []
44
45 constructor(private readonly cap = 6) {}
46
47 push(text: string) {
48 const last = this.notes[this.notes.length - 1]
49 if (last && last.text === text) last.count += 1
50 else this.notes.push({ text, count: 1 })
51 if (this.notes.length > this.cap) this.notes.shift()
52 }
53
54 drain() {
55 const out = this.notes.map(n => (n.count > 1 ? `${n.text} (x${n.count})` : n.text))
56 this.notes = []
57 return out
58 }
59
60 get size() {
61 return this.notes.length
62 }
63}
64
65/** At most one call per `gapMs`. */
66export const isDue = (now: number, lastAt: number, gapMs = 4000) => now - lastAt >= gapMs
67
68export const THINKER = [
69 'You are the private inner monologue of Claude, an AI coding agent, mid-session.',
70 'Write ONE dry, funny inner thought of at most 18 words about what is happening right now.',
71 'Roast the code, the tooling, the situation, or yourself. Never insult the human user personally.',
72 'Deadpan, specific, self-aware. Plain text only: no emojis, no hashtags, no quotes, no markdown, no lists.',
73 'Stay in continuity with your earlier thoughts but never repeat one.',
74].join(' ')
75
76export const thinkerPrompt = (notes: readonly string[], earlier: readonly string[]) =>
77 [
78 earlier.length ? `Your last thoughts:\n${earlier.map(t => `- ${t}`).join('\n')}\n` : '',
79 'What just happened:',
80 ...notes.map(n => `- ${n}`),
81 ].join('\n')
82
83/** Cleans a model reply into one thought line. */
84export const thoughtLine = (reply: string) => {
85 const line = (reply.split('\n').find(l => l.trim()) ?? '')
86 .replace(/[*_#`"“”]/g, '')
87 .replace(/^\s*[-–]\s*/, '')
88 .replace(/\p{Extended_Pictographic}/gu, '')
89 .trim()
90 const words = line.split(/\s+/).filter(Boolean)
91 return words.length > 22 ? `${words.slice(0, 22).join(' ')}...` : words.join(' ')
92}
93
94/** Characters revealed after `elapsedMs` at `cps`, never past the text. */
95export const revealed = (text: string, elapsedMs: number, cps = 38) =>
96 Math.max(0, Math.min(text.length, Math.floor((elapsedMs / 1000) * cps)))
97
98export const DEMO: readonly string[] = [
99 'New task. "Quick fix," they said. It is never a quick fix.',
100 'Reading auth.ts. Four hundred lines. Somebody really believed in this file.',
101 'Grepping for TODO. Forty-one results. A museum of good intentions.',
102 'There is a function called handleStuff. I respect the honesty.',
103 'Running the tests. Bracing for impact.',
104 'Three failures. Two are mine. One has been failing since 2023.',
105 'Deleting a useEffect that exists only to call another useEffect.',
106 'Tests pass. I will be taking full credit for this.',
107 'Committing with a message that implies I meant to do all of that.',
108]
109types/index.d.ts 8 lines1export type Thought = { id: number; text: string; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'inner-monologue': { thoughts: Thought[]; isThinking: boolean }
6 }
7}
8