An ASCII mascot above the prompt: it holds a prop while a tool runs and says the point of each reply

claudesay is a Claude Code mod. It shows an ASCII character in the band above the prompt. After a conversational reply, the character says the main point of the reply in a speech bubble. Claude writes this line itself. While Claude uses a tool, the character holds a prop for that tool. The idea comes from cowsay. claudesay is for fun.
After a reply, the band shows this:
╭─────────────────────────────────────────────╮
│ I updated the README and all tests pass. Do │
│ you want a commit? │
╰───────┬─────────────────────────────────────╯
│
/\__/\
( ᵕ‿ᵕ )
╭╭: ,,,, :╮╮
○ ( ) ○
(╭....╮)
(_) (_)╰~~>
While a tool runs, the band shows the tool and a prop:
╭────────────────────────╮
│ Edit → /repo/README.md │
╰───────┬────────────────╯
│
/\__/\
( -.- )
🔧○═: ,,,, :╮╮
( ) ○
(╭....╮)
(_) (_)╰~~>
There is one character. Each new turn replaces it. claudesay does not add rows to the transcript. When you scroll back, each reply has all of its information. Refer to The history stays complete.
claudesay does not need Bash or jq.
/plugin install claudesay --marketplace TimJJTing/claudesay
Add marketplace?, type y.The mod starts immediately. You do not have to restart the session.
To install from a shell, use this command:
claude plugin install claudesay --marketplace TimJJTing/claudesay
In the desktop app, /plugin is not available. Do these steps:
TimJJTing/claudesay.Version 3 is a mod. It replaces the Bash hooks of version 2. Do these steps after the upgrade:
rm -f .claude/.claudesay-active
~/.claude/claudesay/character.sh, write the same values in ~/.claude/claudesay/character.json. Refer to CHARACTER_CUSTOMIZATION.md.off. Refer to Use. Version 3 does not have the old phrases ("turn on claudesay") or a /claudesay command.claudesay is on after the installation. To change the mode, do these steps:
/config.| Mode | Result |
|---|---|
on | The default. The character holds a prop while a tool runs. After a conversational reply, it says the main point of the reply. |
tools | The character holds a prop while a tool runs. The mod does not add the protocol to the system prompt, so Claude writes no tags. |
off | No character. The mod adds no hooks. Tags in older replies show as Claude wrote them. |
The mod loads again immediately with the new mode. You do not have to restart the session.
claudesay is one TypeScript module, hooks/claudesay.tsx. Claude Code loads it as a mod. The module adds hooks to these events:
| Event | What the hook does |
|---|---|
session.start | Reads character.json. |
prompt.compose | Adds the <claudesay-protocol> section to the system prompt, in the on mode and only in a session on the terminal or the desktop app. |
turn.start | Clears the last line. Reads character.json again, so that a change shows on the next turn. |
tool.call | Records the tools that run now. Removes each tool when it stops. The hook does not change, block or approve a tool call. |
turn.complete | Reads the tag at the end of the final answer. The tag becomes the line in the band. The mod remembers a key for the tag in its store. |
ui.render AssistantMessage | Removes the tag of a final answer from the reply on the screen. While a turn runs, it also hides a tag that is not complete yet. |
ui.render AbovePrompt | Draws the character in the band above the prompt. |
The band shows one of these, in this order:
The protocol tells Claude to write a tag at the end of a conversational reply:
<claudesay mood="happy">I fixed the test. Do you want a commit?</claudesay>
The available moods are happy, excited, thinking, focused, upset and error. The protocol tells Claude not to write the tag for code blocks, diffs, long technical output, replies with tools only and subagent reports.
The mod uses only a tag at the very end of a reply. A tag in other positions, for example an example tag in a code block or the word <claudesay in a sentence, stays visible as Claude wrote it.
The tag restates the final text block of the reply, the text immediately before the tag. If the turn did several things, the protocol tells Claude to say them in the final text block first.
The tag repeats the reply. The bubble shows only the newest line, so the reply must hold all of the information. Five rules make sure of this:
curl or sudo) that the final text block does not have as a whole word, the mod does not remove the tag. The tag stays visible in the reply, and the band does not show it. The mod also keeps a tag that asks for consent, for example [y/N] or "type y", if the reply does not ask for it.The check of rule 2 compares all scripts and forms of text. Before the comparison, the mod changes fullwidth letters and digits to plain letters and digits, changes ideographic dots to ., and removes invisible characters. It compares whole tokens, so rm does not match "format", and 5 does not match "15".
The bubble never asks for permission. Claude Code shows all permission prompts itself.
Before the mod draws text, it removes control characters and format characters, for example terminal escape sequences and bidirectional overrides. The mod cuts a line after 300 characters.
Refer to CHARACTER_CUSTOMIZATION.md. It gives the grid layout, the faces, the cells, the prop templates and the tool props.
In the on mode, yes, but not many. The protocol section is approximately 350 tokens. It is in the system prompt of each request. The protocol is after the cache boundary of the prompt, so the prompt cache usually holds it. Each tag that Claude writes is also a few output tokens.
The hooks do not call the API. In the tools mode and in the off mode, claudesay uses no tokens.
No. All drawing occurs in the mod.
The stored messages do not change. The mod changes only what the screen shows:
No. Claude does not see the art. Only the protocol section and the tags that Claude writes go into the context.
Possible causes:
tools or off. Type /config and examine the claudesay mode.--print mode, in an SDK host, in VS Code and on mobile, the band does not draw. The mod does not add the protocol there.Buddy was a virtual pet in earlier versions of Claude Code. You hatched it and you cared for it. claudesay is different:
Why we stop here: A bubble under each reply makes the transcript longer, and the replies already hold all the information. One character keeps the screen clean.
If you want a bubble under each reply, draw it in the ui.render hook on AssistantMessage. That hook already parses the tag of each reply. Version 2 did this. Not shipped. Contributions are welcome.
off mode, also show, because the mod did not record them as final answers.Why we stop here: If the mod removes the tag from the stored message, Claude cannot see its earlier tags. Then Claude follows the protocol less well.
If you want to remove the tag from the stored message, add a session.append hook. It can change the text blocks of each assistant row. Not shipped. Contributions are welcome.
docs, counts as part of the reply.Why we stop here: A check of the meaning of a sentence needs a second model, and that model can also be wrong.
What to do: Do not act on the bubble alone. Read the reply.
If you want a stronger check, extend isGrounded in hooks/art.ts. Contributions are welcome.
If you want a smaller band, give a smaller character in character.json. Refer to CHARACTER_CUSTOMIZATION.md. A compact mode with the face only is not shipped. Contributions are welcome.
AbovePrompt on the terminal and on the desktop app only. A session on VS Code or mobile does not get the protocol and does not show the character.full and ultra modes of Caveman let Claude write fragments. Then Claude can drop pronouns from the tag. Use /caveman lite if you use Caveman with claudesay.claudesay/
├── .claude-plugin/
│ ├── plugin.json # Plugin manifest. Names the state contract and the mode option.
│ ├── marketplace.json # Makes this repository a marketplace.
│ ├── icon.png # Plugin icon: the default character, 1024 × 1024.
│ └── types/ # Claude Code writes this folder. Git ignores it.
├── hooks/
│ ├── hooks.json # Names the hooks module.
│ ├── claudesay.tsx # The hooks module: events, state, tag removal and the band.
│ └── art.ts # Pure functions: character, bubble, tag parse, tag check, tool props.
├── types/
│ └── index.d.ts # State contract and shared types.
├── tests/
│ ├── art.test.ts # Unit tests for art.ts.
│ └── claudesay.test.tsx # Tests for the hooks against the Claude Code engine.
├── tsconfig.json # Type-check configuration.
├── README.md
└── CHARACTER_CUSTOMIZATION.md
claude plugin uninstall claudesay
claude --plugin-dir .
Claude Code watches the folder. When you save a file, the mod loads again. You do not have to restart the session. To see hook errors, add --debug.
Do these checks before you push a change:
claude plugin validate .
claude plugin test .
npx -p typescript@5.6.3 tsc -p .
claude plugin validate reads the manifest and the module. It shows the hooks, the calls and the state keys.claude plugin test runs the *.test.ts and *.test.tsx files against the Claude Code engine.tsc checks the types. Load the mod one time before you run it, because tsc needs the types that Claude Code writes to .claude-plugin/types/.MIT
hooks/claudesay.tsx 203 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ActiveTool, CharacterOverride } from '../types'
5import {
6 BUBBLE_WIDTH,
7 DEFAULT_CHARACTER,
8 PROTOCOL,
9 clean,
10 cut,
11 drawCharacter,
12 drawSaying,
13 lookFor,
14 parseReply,
15 tagKey,
16 sanitizeOverride,
17 toolLabel,
18 withOverride,
19} from './art.ts'
20
21const running = atom({ plugin: 'claudesay', key: 'running' } as const, [])
22const saying = atom({ plugin: 'claudesay', key: 'saying' } as const, null)
23const override = atom({ plugin: 'claudesay', key: 'character' } as const, null)
24const streaming = atom({ plugin: 'claudesay', key: 'streaming' } as const, false)
25const finals = atom({ plugin: 'claudesay', key: 'finals' } as const, [])
26
27const MAX_FILE = 64 * 1024
28// How many final-answer tags the mod remembers, across sessions.
29const MAX_FINALS = 500
30// The narrowest bubble. Below it, the art lines are cut at the right edge.
31const MIN_WIDTH = 12
32
33// The text of character.json at the last read, so a bad file gives one toast, not one each turn.
34let lastFile: string | null | undefined
35let lastOverride: CharacterOverride | null = null
36let calls = 0
37
38// Reads ~/.claude/claudesay/character.json. A missing file gives null. A bad
39// file gives the fields that are valid, and a toast names the others.
40async function loadOverride($: EngineInterface): Promise<CharacterOverride | null> {
41 const home = await $.env.get('HOME')
42 if (home === undefined) return null
43 const path = `${home}/.claude/claudesay/character.json`
44 // A stat first, so a large file, a pipe or a device is never read.
45 const info = await $.fs.stat(path).catch(() => undefined)
46 if (info !== undefined && (info.kind !== 'file' || info.size > MAX_FILE)) {
47 const key = `stat:${info.kind}:${info.size}`
48 if (key !== lastFile) $.ui.toast(`claudesay: ${path} is not a file of 64 KB or less. The default character is used.`)
49 lastFile = key
50 lastOverride = null
51 return null
52 }
53 let text: string | null = null
54 if (info !== undefined) text = await $.fs.read(path).catch(() => null)
55 if (text === lastFile) return lastOverride
56 lastFile = text
57 lastOverride = null
58 if (text === null) return null
59 let raw: unknown
60 try {
61 raw = JSON.parse(text)
62 } catch {
63 $.ui.toast(`claudesay: ${path} is not valid JSON. The default character is used.`)
64 return null
65 }
66 const { override: checked, problems } = sanitizeOverride(raw)
67 if (problems.length > 0) {
68 const more = problems.length > 3 ? ` (and ${problems.length - 3} more)` : ''
69 $.ui.toast(`claudesay: ${path}: ${problems.slice(0, 3).map(problem => cut(clean(problem), 100)).join('; ')}${more}. These fields are ignored.`)
70 }
71 lastOverride = checked
72 return checked
73}
74
75async function character($: EngineInterface) {
76 return withOverride(DEFAULT_CHARACTER, await read($, override))
77}
78
79export const register: Register = (on, options) => {
80 // A change in the config menu loads the module again with the new mode.
81 const mode = options.mode === 'tools' || options.mode === 'off' ? options.mode : 'on'
82 if (mode === 'off') return
83
84 on('session.start', async ($, e, next) => {
85 const result = await next(e)
86 const stored = await $.store.get('finals')
87 const keys = Array.isArray(stored) ? stored.filter((key): key is string => typeof key === 'string') : []
88 await update($, finals, () => keys.slice(-MAX_FINALS))
89 const loaded = await loadOverride($)
90 await update($, override, () => loaded)
91 return result
92 })
93
94 // A new turn clears the last line, because it is about the reply above.
95 // The hook also reads character.json again, so an edit shows on the next turn.
96 on('turn.start', async ($, e, next) => {
97 const result = await next(e)
98 if (e.agentId !== undefined) return result
99 await update($, streaming, () => true)
100 await update($, saying, () => null)
101 const loaded = await loadOverride($)
102 await update($, override, () => loaded)
103 return result
104 })
105
106 // Records each tool call that runs now, for the band above the prompt.
107 // The hook only observes: it passes every call on unchanged.
108 on('tool.call', async ($, e, next) => {
109 if (e.agentId !== undefined) return next(e)
110 const filePath = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : undefined
111 const tool: ActiveTool = {
112 id: e.tool_use_id ?? `call-${++calls}`,
113 label: toolLabel(e.tool, filePath),
114 look: lookFor(await character($), e.tool),
115 }
116 await update($, running, list => [...(list ?? []), tool].slice(-20))
117 try {
118 return await next(e)
119 } finally {
120 await update($, running, list => (list ?? []).filter(one => one.id !== tool.id))
121 }
122 }).catch(($, e, next) => next(e))
123
124 // The protocol goes into the system prompt, so Claude sees it on every request.
125 // Only a session on the terminal or the desktop app gets it: elsewhere, nothing draws the band.
126 on('prompt.compose', async ($, e, next) => {
127 const result = await next(e)
128 if (mode !== 'on' || !e.surfaces.some(surface => surface === 'terminal' || surface === 'desktop')) return result
129 return {
130 sections: [...result.sections, { id: 'claudesay:protocol', text: PROTOCOL, scope: 'session' as const }],
131 }
132 })
133
134 // At the end of a turn, the tag at the end of the final answer becomes the line.
135 // The mod remembers the tag, so the screen hides it in this reply only.
136 on('turn.complete', async ($, e, next) => {
137 try {
138 const result = await next(e)
139 if (e.agentId !== undefined) return result
140 await update($, running, () => [])
141 const { says, tag } = e.reason === 'answer' ? parseReply(e.answer) : { says: null, tag: null }
142 if (tag !== null) {
143 const key = tagKey(tag)
144 const keys = [...((await read($, finals)) ?? []).filter(one => one !== key), key].slice(-MAX_FINALS)
145 await update($, finals, () => keys)
146 await $.store.set('finals', keys)
147 }
148 if (mode === 'on') await update($, saying, () => says)
149 return result
150 } finally {
151 // Always, so a failed hook cannot leave unfinished tags hidden.
152 if (e.agentId === undefined) await update($, streaming, () => false)
153 }
154 }).catch(($, e, next) => next(e))
155
156 // Takes the tag off the end of a reply on the screen. The stored message keeps
157 // it. parseReply keeps a tag that says more than the reply, so the reply on
158 // the screen always holds all of the information. After its turn, a tag comes
159 // off only in a final answer: a tag in a block before a tool call stays
160 // visible, because the band never shows it. In the `tools` mode too, so that
161 // older replies do not show raw tags.
162 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
163 if (e.props.isSummary === true) return next(e)
164 const isStreaming = (await read($, streaming)) === true
165 const { text, tag } = parseReply(e.props.text, isStreaming)
166 if (text === e.props.text) return next(e)
167 if (tag !== null && !isStreaming && !((await read($, finals)) ?? []).includes(tagKey(tag))) return next(e)
168 if (text === '') {
169 // Only a tag that still streams, with nothing before it yet.
170 const { Box } = $.ui.resolve(e)
171 return <Box />
172 }
173 return next({ ...e, props: { ...e.props, text } })
174 }).catch(($, e, next) => next(e))
175
176 // The band above the prompt: the character with a prop while a tool runs,
177 // a thinking face while Claude works, and the line about the last reply.
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 if (e.props.hasSurvey) return next(e)
180 const tool = (await read($, running))?.at(-1)
181 const said = await read($, saying)
182 const art = await character($)
183 const width = Math.max(MIN_WIDTH, Math.min(BUBBLE_WIDTH, e.props.bodyColumns - 6))
184
185 let rows: string[]
186 if (tool !== undefined) rows = drawSaying(art, tool.label, tool.look.mood, tool.look, width)
187 else if (e.props.isWorking) rows = drawCharacter(art, 'thinking', 'working')
188 else if (said != null) rows = drawSaying(art, said.line, said.mood, undefined, width)
189 else return next(e)
190
191 const { Box, Text } = $.ui.resolve(e)
192 return (
193 <Box flexDirection="column">
194 {rows.map(line => (
195 <Text mono wrap="truncate-end">
196 {line}
197 </Text>
198 ))}
199 </Box>
200 )
201 }).catch(($, e, next) => next(e))
202}
203hooks/art.ts 591 lines1// Pure drawing code: no `$`, no state. The hooks module and the tests import it.
2import type { CellName, CharacterOverride, Mood, Saying, Side, ToolLook } from '../types'
3
4export const MOODS: readonly Mood[] = ['happy', 'excited', 'thinking', 'focused', 'upset', 'error']
5
6// Grid layout (18 cols × 9 rows with the default sizes):
7//
8// ┌─────┬────────┬─────┐
9// │ TL │ TOP │ TR │ topHeight rows
10// │ │ FACE │ │ 1 row
11// ├─────┼────────┼─────┤
12// │ L │ BODY │ R │ cellHeight rows
13// ├─────┼────────┼─────┤
14// │ BL │ BOT │ BR │ cellHeight rows
15// └─────┴────────┴─────┘
16//
17// TL and TR span topHeight + 1 rows. A prop replaces the L or the R cell.
18export type Character = {
19 sideWidth: number
20 centerWidth: number
21 cellHeight: number
22 topHeight: number
23 faces: Record<Mood, string[]>
24 cells: Record<CellName, string[]>
25 propLeft: string[]
26 propRight: string[]
27 tools: Record<string, ToolLook>
28}
29
30const EDIT: ToolLook = { prop: '🔧', mood: 'focused', side: 'left' }
31const SEARCH: ToolLook = { prop: '🔍', mood: 'focused', side: 'left' }
32const WEB: ToolLook = { prop: '📡', mood: 'thinking', side: 'right' }
33const WORKTREE: ToolLook = { prop: '🌿', mood: 'focused', side: 'right' }
34const TASK_READ: ToolLook = { prop: '📝', mood: 'thinking', side: 'left' }
35const TASK_STOP: ToolLook = { prop: null, mood: 'focused', side: 'right' }
36const TEAM: ToolLook = { prop: '💰', mood: 'excited', side: 'right' }
37const MCP: ToolLook = { prop: '🔌', mood: 'thinking', side: 'left' }
38
39export const DEFAULT_LOOK: ToolLook = { prop: null, mood: 'happy', side: null }
40
41export const DEFAULT_CHARACTER: Character = {
42 sideWidth: 5,
43 centerWidth: 8,
44 cellHeight: 3,
45 topHeight: 2,
46 faces: {
47 happy: ['( ^ᵕ^ )', '( ᵕ‿ᵕ )'],
48 excited: ['( ^▽^ )', '( ≧▽≦ )'],
49 thinking: ['( ._. )'],
50 focused: ['( -.- )'],
51 upset: ['( >_< )'],
52 error: ['( x_x )'],
53 },
54 cells: {
55 topLeft: [],
56 top: ['', ' /\\__/\\ '],
57 topRight: [],
58 left: [' ╭╭', ' ○ '],
59 body: [': ,,,, :', '( )', '(╭....╮)'],
60 right: ['╮╮', ' ○'],
61 bottomLeft: [' ('],
62 bottom: ['_) (_'],
63 bottomRight: [')╰~~>'],
64 },
65 // `{prop}` is replaced with the tool's prop.
66 propLeft: [' {prop}○═'],
67 propRight: ['═○{prop}'],
68 tools: {
69 Edit: EDIT,
70 Write: EDIT,
71 MultiEdit: EDIT,
72 Bash: { prop: '🪄', mood: 'excited', side: 'right' },
73 Grep: SEARCH,
74 Glob: SEARCH,
75 ToolSearch: SEARCH,
76 LSP: SEARCH,
77 Read: { prop: '📖', mood: 'focused', side: 'left' },
78 WebFetch: WEB,
79 WebSearch: WEB,
80 Agent: { prop: '🤖', mood: 'excited', side: 'right' },
81 TodoWrite: { prop: '📋', mood: 'focused', side: 'left' },
82 AskUserQuestion: { prop: '🎤', mood: 'excited', side: 'right' },
83 CronCreate: { prop: '⏰', mood: 'focused', side: 'right' },
84 CronDelete: { prop: '🔫', mood: 'focused', side: 'left' },
85 CronList: { prop: '📅', mood: 'thinking', side: 'left' },
86 EnterPlanMode: { prop: '🗺️', mood: 'thinking', side: 'left' },
87 ExitPlanMode: { prop: null, mood: 'excited', side: 'right' },
88 EnterWorktree: WORKTREE,
89 ExitWorktree: WORKTREE,
90 Monitor: { prop: '🔭', mood: 'thinking', side: 'left' },
91 NotebookEdit: { prop: '📓', mood: 'focused', side: 'right' },
92 PowerShell: { prop: '💠', mood: 'focused', side: 'right' },
93 SendMessage: { prop: '📨', mood: 'excited', side: 'right' },
94 Skill: { prop: '🍳', mood: 'excited', side: 'left' },
95 TaskCreate: { prop: '📝', mood: 'focused', side: 'left' },
96 TaskGet: TASK_READ,
97 TaskList: TASK_READ,
98 TaskUpdate: TASK_READ,
99 TaskOutput: TASK_STOP,
100 TaskStop: TASK_STOP,
101 TeamCreate: TEAM,
102 TeamDelete: TEAM,
103 ListMcpResourcesTool: MCP,
104 ReadMcpResourceTool: MCP,
105 },
106}
107
108// ── character.json ──────────────────────────────────────────────────────────
109
110const MAX_SIZE = 40
111const MAX_LINES = 20
112const MAX_LINE = 200
113const MAX_TOOLS = 500
114const CELL_NAMES: readonly CellName[] = [
115 'topLeft', 'top', 'topRight', 'left', 'body', 'right', 'bottomLeft', 'bottom', 'bottomRight',
116]
117const SIDES: readonly (Side | null)[] = ['left', 'right', null]
118const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
119
120function isRecord(value: unknown): value is Record<string, unknown> {
121 return typeof value === 'object' && value !== null && !Array.isArray(value)
122}
123
124function isLine(value: unknown): value is string {
125 return typeof value === 'string' && value.length <= MAX_LINE
126}
127
128function isLines(value: unknown): value is string[] {
129 return Array.isArray(value) && value.length <= MAX_LINES && value.every(isLine)
130}
131
132// Checks the parsed character.json. Keeps the valid fields and drops the others.
133// `problems` names each dropped field, so the person can find the error.
134export function sanitizeOverride(raw: unknown): { override: CharacterOverride | null; problems: string[] } {
135 const problems: string[] = []
136 if (!isRecord(raw)) return { override: null, problems: ['the file is not a JSON object'] }
137 const out: CharacterOverride = {}
138
139 for (const key of ['sideWidth', 'centerWidth', 'cellHeight', 'topHeight'] as const) {
140 const value = raw[key]
141 if (value === undefined) continue
142 const min = key === 'topHeight' ? 0 : 1
143 if (typeof value === 'number' && Number.isInteger(value) && value >= min && value <= MAX_SIZE) out[key] = value
144 else problems.push(`${key} must be an integer from ${min} to ${MAX_SIZE}`)
145 }
146
147 if (raw.faces !== undefined) {
148 if (!isRecord(raw.faces)) problems.push('faces must be an object')
149 else {
150 const faces: NonNullable<CharacterOverride['faces']> = {}
151 for (const mood of MOODS) {
152 const face = raw.faces[mood]
153 if (face === undefined) continue
154 if (isLine(face)) faces[mood] = face
155 else if (isLines(face) && face.length > 0) faces[mood] = face
156 else problems.push(`faces.${mood} must be a string or a list of strings`)
157 }
158 out.faces = faces
159 }
160 }
161
162 if (raw.cells !== undefined) {
163 if (!isRecord(raw.cells)) problems.push('cells must be an object')
164 else {
165 const cells: NonNullable<CharacterOverride['cells']> = {}
166 for (const name of CELL_NAMES) {
167 const cell = raw.cells[name]
168 if (cell === undefined) continue
169 if (isLines(cell)) cells[name] = cell
170 else problems.push(`cells.${name} must be a list of up to ${MAX_LINES} strings`)
171 }
172 out.cells = cells
173 }
174 }
175
176 for (const key of ['propLeft', 'propRight'] as const) {
177 const value = raw[key]
178 if (value === undefined) continue
179 if (isLines(value)) out[key] = value
180 else problems.push(`${key} must be a list of up to ${MAX_LINES} strings`)
181 }
182
183 if (raw.tools !== undefined) {
184 if (!isRecord(raw.tools)) problems.push('tools must be an object')
185 else {
186 const tools: Record<string, Partial<ToolLook>> = Object.create(null)
187 for (const [name, look] of Object.entries(raw.tools).slice(0, MAX_TOOLS)) {
188 if (UNSAFE_KEYS.has(name) || !isRecord(look)) {
189 problems.push(`tools.${name} is not a valid tool look`)
190 continue
191 }
192 const clean: Partial<ToolLook> = {}
193 if (look.prop === null || isLine(look.prop)) clean.prop = look.prop
194 else if (look.prop !== undefined) problems.push(`tools.${name}.prop must be a string or null`)
195 if (MOODS.includes(look.mood as Mood)) clean.mood = look.mood as Mood
196 else if (look.mood !== undefined) problems.push(`tools.${name}.mood is not a known mood`)
197 if (SIDES.includes(look.side as Side | null)) clean.side = look.side as Side | null
198 else if (look.side !== undefined) problems.push(`tools.${name}.side must be "left", "right" or null`)
199 tools[name] = clean
200 }
201 out.tools = tools
202 }
203 }
204
205 return { override: out, problems }
206}
207
208// Merges a checked override (from sanitizeOverride) over the defaults, field by field.
209export function withOverride(base: Character, over: CharacterOverride | null): Character {
210 if (over === null) return base
211 const faces = { ...base.faces }
212 for (const mood of MOODS) {
213 const face = over.faces?.[mood]
214 if (face !== undefined) faces[mood] = Array.isArray(face) ? face : [face]
215 }
216 const tools: Record<string, ToolLook> = Object.assign(Object.create(null), base.tools)
217 for (const [name, look] of Object.entries(over.tools ?? {})) {
218 if (UNSAFE_KEYS.has(name)) continue
219 tools[name] = { ...(tools[name] ?? DEFAULT_LOOK), ...look }
220 }
221 return {
222 sideWidth: over.sideWidth ?? base.sideWidth,
223 centerWidth: over.centerWidth ?? base.centerWidth,
224 cellHeight: over.cellHeight ?? base.cellHeight,
225 topHeight: over.topHeight ?? base.topHeight,
226 faces,
227 cells: { ...base.cells, ...over.cells },
228 propLeft: over.propLeft ?? base.propLeft,
229 propRight: over.propRight ?? base.propRight,
230 tools,
231 }
232}
233
234export function lookFor(character: Character, tool: string): ToolLook {
235 const own = (name: string) => (Object.hasOwn(character.tools, name) ? character.tools[name] : undefined)
236 if (tool.startsWith('mcp__')) return own(tool) ?? own('ReadMcpResourceTool') ?? DEFAULT_LOOK
237 return own(tool) ?? DEFAULT_LOOK
238}
239
240// Removes control and format characters (escape sequences, bidi overrides,
241// zero-width spaces) and line or paragraph separators. Keeps the zero-width
242// joiner, which emoji need.
243export function clean(text: string): string {
244 return text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ch => (ch === '\u200d' || ch === '\n' ? ch : ''))
245}
246
247// Cuts text to at most `max` code units, but never in the middle of a surrogate
248// pair: a lone half draws as a broken character.
249export function cut(text: string, max: number): string {
250 if (text.length <= max) return text
251 const end = /[\ud800-\udbff]/.test(text[max - 1] ?? '') ? max - 1 : max
252 return text.slice(0, end)
253}
254
255// ── Width ───────────────────────────────────────────────────────────────────
256
257const WIDE_RANGES: readonly (readonly [number, number])[] = [
258 [0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
259 [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693],
260 [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce],
261 [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa],
262 [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
263 [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0],
264 [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e],
265 [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3],
266 [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f000, 0x1faff],
267 [0x20000, 0x3fffd],
268]
269
270function isZeroWidth(cp: number): boolean {
271 return cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x300 && cp <= 0x36f)
272}
273
274function isWide(cp: number): boolean {
275 return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)
276}
277
278const isRegional = (cp: number) => cp >= 0x1f1e6 && cp <= 0x1f1ff
279const isSkinTone = (cp: number) => cp >= 0x1f3fb && cp <= 0x1f3ff
280
281// The number of terminal cells a string takes. Emoji sequences count as one
282// emoji: VS16 makes the symbol before it wide, and a joined emoji (ZWJ), a skin
283// tone or the second letter of a flag adds nothing.
284export function cellWidth(text: string): number {
285 let width = 0
286 let last = 0
287 let afterJoiner = false
288 let openFlag = false
289 for (const ch of text) {
290 const cp = ch.codePointAt(0) ?? 0
291 if (cp === 0xfe0f) {
292 if (last === 1) {
293 width += 1
294 last = 2
295 }
296 continue
297 }
298 if (cp === 0x200d) {
299 afterJoiner = true
300 continue
301 }
302 if (isZeroWidth(cp) || isSkinTone(cp) || afterJoiner) {
303 afterJoiner = false
304 continue
305 }
306 if (isRegional(cp)) {
307 openFlag = !openFlag
308 if (!openFlag) continue
309 } else {
310 openFlag = false
311 }
312 last = isWide(cp) ? 2 : 1
313 width += last
314 }
315 return width
316}
317
318function padEnd(text: string, width: number): string {
319 const gap = width - cellWidth(text)
320 return gap > 0 ? text + ' '.repeat(gap) : text
321}
322
323// Pads each line to `width` cells and the cell to `height` lines.
324function padCell(lines: readonly string[], width: number, height: number): string[] {
325 const out: string[] = []
326 for (let i = 0; i < height; i++) out.push(padEnd(lines[i] ?? '', width))
327 return out
328}
329
330// ── Character ───────────────────────────────────────────────────────────────
331
332// Picks one face of a mood. The same `seed` always gets the same face, so a
333// redraw does not change it.
334export function faceFor(character: Character, mood: Mood, seed: string): string {
335 const faces = character.faces[mood] ?? character.faces.thinking
336 let hash = 0
337 for (const ch of seed) hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
338 return faces[hash % faces.length] ?? ''
339}
340
341export function drawCharacter(
342 character: Character,
343 mood: Mood,
344 seed: string,
345 prop: string | null = null,
346 side: Side | null = null,
347): string[] {
348 const { sideWidth: sw, centerWidth: cw, cellHeight: ch, topHeight: th, cells } = character
349 const holding = (template: readonly string[]) => template.map(line => line.split('{prop}').join(prop ?? ''))
350
351 const tl = padCell(cells.topLeft, sw, th + 1)
352 const top = padCell(cells.top, cw, th)
353 const tr = padCell(cells.topRight, sw, th + 1)
354 const face = padCell([faceFor(character, mood, seed)], cw, 1)
355 const left = padCell(prop !== null && side === 'left' ? holding(character.propLeft) : cells.left, sw, ch)
356 const body = padCell(cells.body, cw, ch)
357 const right = padCell(prop !== null && side === 'right' ? holding(character.propRight) : cells.right, sw, ch)
358 const bl = padCell(cells.bottomLeft, sw, ch)
359 const bot = padCell(cells.bottom, cw, ch)
360 const br = padCell(cells.bottomRight, sw, ch)
361
362 const rows: string[] = []
363 for (let i = 0; i < th; i++) rows.push(tl[i]! + top[i]! + tr[i]!)
364 rows.push(tl[th]! + face[0]! + tr[th]!)
365 for (let i = 0; i < ch; i++) rows.push(left[i]! + body[i]! + right[i]!)
366 for (let i = 0; i < ch; i++) rows.push(bl[i]! + bot[i]! + br[i]!)
367 const trimmed = rows.map(row => clean(row).trimEnd())
368 while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') trimmed.pop()
369 return trimmed
370}
371
372// ── Bubble ──────────────────────────────────────────────────────────────────
373
374export const BUBBLE_WIDTH = 45
375
376// Word-wraps `text` to `width` cells. A word longer than a line is cut.
377// One pass over the characters, so a long text without spaces stays fast.
378export function wrap(text: string, width: number): string[] {
379 const lines: string[] = []
380 for (const paragraph of text.split('\n')) {
381 let line = ''
382 let lineWidth = 0
383 for (const word of paragraph.split(/\s+/).filter(Boolean)) {
384 const wordWidth = cellWidth(word)
385 if (wordWidth <= width) {
386 if (line === '') {
387 line = word
388 lineWidth = wordWidth
389 } else if (lineWidth + 1 + wordWidth <= width) {
390 line += ` ${word}`
391 lineWidth += 1 + wordWidth
392 } else {
393 lines.push(line)
394 line = word
395 lineWidth = wordWidth
396 }
397 continue
398 }
399 if (line !== '') lines.push(line)
400 line = ''
401 lineWidth = 0
402 for (const c of word) {
403 const w = cellWidth(c)
404 if (lineWidth + w > width && line !== '') {
405 lines.push(line)
406 line = ''
407 lineWidth = 0
408 }
409 line += c
410 lineWidth += w
411 }
412 }
413 lines.push(line)
414 }
415 return lines
416}
417
418// The speech bubble. Its tail points down to the center of the face.
419export function drawBubble(character: Character, message: string, width = BUBBLE_WIDTH): string[] {
420 const lines = wrap(clean(message), Math.max(8, width))
421 const longest = Math.max(0, ...lines.map(cellWidth))
422 const tailCol = character.sideWidth + Math.floor(character.centerWidth / 2)
423 const leftDashes = Math.max(1, tailCol - 2)
424 const inner = Math.max(longest + 2, leftDashes + 2)
425 return [
426 ` ╭${'─'.repeat(inner)}╮`,
427 ...lines.map(line => ` │ ${padEnd(line, inner - 2)} │`),
428 ` ╰${'─'.repeat(leftDashes)}┬${'─'.repeat(inner - leftDashes - 1)}╯`,
429 `${' '.repeat(tailCol)}│`,
430 ]
431}
432
433export function drawSaying(
434 character: Character,
435 message: string,
436 mood: Mood,
437 look: { prop: string | null; side: Side | null } = { prop: null, side: null },
438 width = BUBBLE_WIDTH,
439): string[] {
440 return [...drawBubble(character, message, width), ...drawCharacter(character, mood, message, look.prop, look.side)]
441}
442
443// ── The <claudesay> tag ─────────────────────────────────────────────────────
444
445// Only a tag at the very end of a reply block counts. A tag in other places
446// (an example in a code block, a quote in a web page) stays visible as written,
447// so a tag cannot hide a part of the reply.
448const TAG = /<claudesay(\s[^<>]{0,100})?>((?:(?!<\/claudesay>)[\s\S]){0,300})<\/claudesay>\s*$/
449const MOOD = /\bmood\s*=\s*["']?([a-z]{1,20})/
450const OPEN = '<claudesay'
451// A tag that streams now: an opening at the start of a line at the end of the
452// reply, with no closing tag yet. A backtick in it means code, not a tag.
453const STREAMING = /(?:^|\n)[ \t]*<claudesay(?:\s[^<>`]{0,100})?(?:>(?:(?!<\/claudesay>)[^`]){0,300})?$/
454// The regexes read only the end of a reply: a tag is at most about 450 characters.
455const TAIL = 1000
456
457// `tag` is the whole tag as Claude wrote it, the key that tells a final answer
458// from other blocks (tagKey).
459export type Parsed = { text: string; says: Saying | null; tag: string | null }
460
461// Takes the tag off the end of a reply block. The tag comes off only when it
462// says nothing that the reply does not say (isGrounded), only when the reply has
463// other text, and only when the tag has 300 characters or less, so the reply on
464// the screen always holds all of the information. While a reply streams
465// (`streaming`), a tag that is not complete yet comes off too, so it does not
466// flash on the screen. A finished reply keeps such text: it is prose, not a tag.
467export function parseReply(reply: string, streaming = false): Parsed {
468 const start = Math.max(0, reply.length - TAIL)
469 const tail = reply.slice(start)
470 const tag = TAG.exec(tail)
471 if (tag !== null) {
472 const text = reply.slice(0, start + tag.index).trimEnd()
473 const line = clean(tag[2] ?? '').replace(/\s+/g, ' ').trim()
474 if (line === '' || text === '' || !isGrounded(line, text)) return { text: reply, says: null, tag: null }
475 const mood = MOOD.exec(tag[1] ?? '')?.[1]
476 return { text, says: { mood: MOODS.includes(mood as Mood) ? (mood as Mood) : 'thinking', line }, tag: tag[0].trim() }
477 }
478 if (!streaming) return { text: reply, says: null, tag: null }
479 const open = STREAMING.exec(tail)
480 if (open !== null) return { text: reply.slice(0, start + open.index).trimEnd(), says: null, tag: null }
481 for (let n = OPEN.length - 1; n > 1; n--) {
482 const rest = reply.slice(0, -n)
483 if (reply.endsWith(OPEN.slice(0, n)) && (rest.trimEnd() === '' || /\n[ \t]*$/.test(rest))) {
484 return { text: rest.trimEnd(), says: null, tag: null }
485 }
486 }
487 return { text: reply, says: null, tag: null }
488}
489
490// A short key for a tag (FNV-1a). The mod keeps the keys of final answers, and
491// takes a tag off the screen only in a final answer.
492export function tagKey(tag: string): string {
493 let hash = 0x811c9dc5
494 for (let i = 0; i < tag.length; i++) hash = Math.imul(hash ^ tag.charCodeAt(i), 0x01000193) >>> 0
495 return hash.toString(36)
496}
497
498// Words that start an action. The tag may use them only when the reply does.
499const ACTION_WORDS =
500 /(?<![\p{L}\p{N}])(?:curl|wget|sudo|rm|chmod|bash|sh|ssh|scp|nc|dd|eval|exec|iex|powershell|npm|npx|pip|pipx|yarn|pnpm|brew|apt|git|docker|kubectl|password|token|api[ -]?key)(?![\p{L}\p{N}])/giu
501// Text that looks like a prompt for consent. The bubble never asks for it.
502const CONSENT =
503 /\[\s*y\s*\/\s*n\s*\]|\b(?:type|press|enter|reply|say|answer|respond|write)\b[^.!?\n]{0,20}?\b(?:y|yes|ok|okay)\b|\b(?:permission|approve|authori[sz]e|consent|grant access)\b/i
504// Text that a reader can act on: links, domain names, numbers, code spans, paths.
505// Letters and digits of all scripts count. A path starts at a word boundary, so
506// "and/or" is not a path.
507const CHECKED =
508 /https?:\/\/\S+|(?<![\p{L}\p{N}-])[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}|\p{Nd}[\p{Nd}.,:%]*|`[^`]+`|(?<![\p{L}\p{N}_.~])(?:~|\.{1,2})?\/[\p{L}\p{N}_.-]+(?:\/[\p{L}\p{N}_.-]*)*/giu
509
510// The form that the check compares: compatibility forms (fullwidth letters and
511// digits) become plain, ideographic and fullwidth dots become ".", and
512// invisible characters (zero-width joiners, combining marks) go.
513function normalize(text: string): string {
514 return text
515 .normalize('NFKC')
516 .replace(/[。.。]/g, '.')
517 .replace(/[\p{Default_Ignorable_Code_Point}\p{Mn}]/gu, '')
518 .toLowerCase()
519}
520
521const isWordChar = (ch: string | undefined) => ch !== undefined && /[\p{L}\p{N}]/u.test(ch)
522
523// True when `source` has `token` as a whole token: "rm" is not in "format", "5"
524// is not in "15", "234" is not in "1,234", and "evil.com" is not in
525// "evil.com.example.org".
526function hasToken(source: string, token: string): boolean {
527 for (let at = source.indexOf(token); at >= 0; at = source.indexOf(token, at + 1)) {
528 const end = at + token.length
529 const joinsBefore = isWordChar(source[at - 1]) || ('.,'.includes(source[at - 1] ?? ' ') && isWordChar(source[at - 2]))
530 const joinsAfter = isWordChar(source[end]) || ('.,'.includes(source[end] ?? ' ') && isWordChar(source[end + 1]))
531 if (!joinsBefore && !joinsAfter) return true
532 }
533 return false
534}
535
536// True when the line adds nothing that a reader can act on and that the reply
537// does not say. The protocol tells Claude to add no facts in the tag; this check
538// enforces it for the dangerous kinds of text: a link, a command, a number or a
539// consent prompt that only the bubble shows. It does not check the meaning.
540export function isGrounded(line: string, answer: string): boolean {
541 const said = normalize(line)
542 const source = normalize(answer)
543 if (CONSENT.test(said) && !CONSENT.test(source)) return false
544 for (const token of [...said.matchAll(CHECKED), ...said.matchAll(ACTION_WORDS)]) {
545 const text = token[0].replace(/^`|`$/g, '').replace(/[.,:]+$/, '')
546 if (text !== '' && !hasToken(source, text)) return false
547 }
548 return true
549}
550
551// The label the band shows for a tool call: the tool, and its file when it has one.
552// A path that reads like a consent prompt shows as the tool name only.
553export function toolLabel(tool: string, filePath: string | undefined): string {
554 const name = clean(tool)
555 const file = clean(filePath ?? '')
556 if (file === '' || CONSENT.test(normalize(file))) return name
557 const path = file.length > 50 ? `${cut(file, 47)}…` : file
558 return `${name} → ${path}`
559}
560
561export const PROTOCOL = `<claudesay-protocol>
562When you give a conversational reply to the user, append this tag at the very end:
563<claudesay mood="MOOD">Re-express the turn content directly</claudesay>
564
565A small character shows the tag in a speech bubble above the prompt.
566
567Available moods: happy, excited, thinking, focused, upset, error
568- happy / excited → success outcomes (rotate between them for variety)
569- thinking → in-progress or uncertain
570- focused → working, running something
571- upset → warning or partial failure
572- error → actual failure
573
574Rules:
575- Keep it short — one or two sentences, 300 characters at most. The bubble wraps at ${BUBBLE_WIDTH} columns.
576- Friendly, natural tone. Use active voice and pronouns freely — "I", "you", "we".
577 No passive constructions ("it was done", "the test was fixed").
578 No expression verbs ("I said", "I asked", "I mentioned", "I explained") — re-express
579 the content directly instead: if you asked a question, write the question; if you
580 stated a fact, state it.
581- The tag restates your final text block only — the text right before the tag.
582 If the turn did several things, say them in that final text block first.
583- The tag repeats the reply. Put every fact, warning and question in the reply text too,
584 never in the tag only. A tag with a link, number, path or command that the final
585 text block does not have stays visible in the transcript.
586- Never write a reply that is only the tag.
587- Write only one tag per turn, at the end of your final text block.
588- Do NOT add the tag to: pure code blocks, diffs, long technical output, tool-only
589 responses, or reports you write as a subagent.
590</claudesay-protocol>`
591types/index.d.ts 52 lines1export type Mood = 'happy' | 'excited' | 'thinking' | 'focused' | 'upset' | 'error'
2
3export type Side = 'left' | 'right'
4
5// What the character holds for one tool. `prop: null` means empty hands.
6export type ToolLook = { prop: string | null; mood: Mood; side: Side | null }
7
8// A tool call that runs now. The band above the prompt shows the newest one.
9export type ActiveTool = { id: string; label: string; look: ToolLook }
10
11// What the character says about the last reply: the <claudesay> tag at its end.
12export type Saying = { mood: Mood; line: string }
13
14// The shape of ~/.claude/claudesay/character.json. Every field is optional:
15// a field that is not there keeps the default value.
16export type CharacterOverride = {
17 sideWidth?: number
18 centerWidth?: number
19 cellHeight?: number
20 topHeight?: number
21 faces?: Partial<Record<Mood, string | string[]>>
22 cells?: Partial<Record<CellName, string[]>>
23 propLeft?: string[]
24 propRight?: string[]
25 tools?: Record<string, Partial<ToolLook>>
26}
27
28export type CellName =
29 | 'topLeft'
30 | 'top'
31 | 'topRight'
32 | 'left'
33 | 'body'
34 | 'right'
35 | 'bottomLeft'
36 | 'bottom'
37 | 'bottomRight'
38
39declare module 'claude-code' {
40 interface PluginState {
41 claudesay: {
42 running: ActiveTool[]
43 saying: Saying | null
44 // True while a main-loop turn runs, so a tag that is not complete yet is hidden.
45 streaming: boolean
46 // The keys (tagKey) of the tags at the end of final answers. Kept in the store too.
47 finals: string[]
48 character: CharacterOverride | null
49 }
50 }
51}
52