SLOPSHOPPER

Inner Monologue

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

newpaneguardcommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · inner-monologue
│ ┃ Inner monologue ✕ › fix the failing auth test and add an audit log call │ ┃ INNER MONOLOGUE 0 thoughts │ ┃ ⏺ Read(src/auth.ts) │ ┃ Quiet in here. Give Claude something to ⎿ Read 6 lines │ ┃ do. ⏺ 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 │ │ › /monologue │ ⎿ inner-monologue: Inner monologue open. It thinks while the pane │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Inner monologue
INNER MONOLOGUE 0 thoughts Quiet in here. Give Claude something to do.
README

inner-monologue

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

inner-monologue

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.

Install

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

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
inner-monologueNoNoNoYes, a summary of recent tool calls and the first 120 characters of each promptOnly 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.

Source 3 files
hooks/register.tsx 157 lines
1import { 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}
157
hooks/logic.ts 109 lines
1/** 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]
109
types/index.d.ts 8 lines
1export 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