SLOPSHOPPER

claudesay

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

newbandrowsguardtoastprompt
★ 2v3.0.0MITupdated 2026-10-22TimJJTing/Claudesay
A shopper browsing a rack in a slop shop
README

claudesay

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.

Requirements

  • Claude Code 2.1.287 or later. Mods do not work in earlier versions.
  • An interactive session in the terminal or in the desktop app.

claudesay does not need Bash or jq.

Install

  1. In a Claude Code session, type this command:
   /plugin install claudesay --marketplace TimJJTing/claudesay
  1. When Claude Code asks Add marketplace?, type y.
  2. Select a scope. The user scope is the default.

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:

  1. Click + adjacent to the prompt box.
  2. Click Plugins.
  3. Click Add marketplace and type TimJJTing/claudesay.
  4. Click Add plugin.

Upgrade from version 2

Version 3 is a mod. It replaces the Bash hooks of version 2. Do these steps after the upgrade:

  1. Remove the old flag files. Version 3 does not use them:
   rm -f .claude/.claudesay-active
  1. If you have ~/.claude/claudesay/character.sh, write the same values in ~/.claude/claudesay/character.json. Refer to CHARACTER_CUSTOMIZATION.md.
  2. To turn the character off, set the mode to off. Refer to Use. Version 3 does not have the old phrases ("turn on claudesay") or a /claudesay command.
  3. Version 2 drew a bubble under each reply. Version 3 shows one character above the prompt. Refer to Known limitations.

Use

claudesay is on after the installation. To change the mode, do these steps:

  1. Type /config.
  2. Find the claudesay row Mode.
  3. Select a mode:
ModeResult
onThe default. The character holds a prop while a tool runs. After a conversational reply, it says the main point of the reply.
toolsThe character holds a prop while a tool runs. The mod does not add the protocol to the system prompt, so Claude writes no tags.
offNo 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.

How it works

claudesay is one TypeScript module, hooks/claudesay.tsx. Claude Code loads it as a mod. The module adds hooks to these events:

EventWhat the hook does
session.startReads character.json.
prompt.composeAdds 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.startClears the last line. Reads character.json again, so that a change shows on the next turn.
tool.callRecords the tools that run now. Removes each tool when it stops. The hook does not change, block or approve a tool call.
turn.completeReads 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 AssistantMessageRemoves 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 AbovePromptDraws the character in the band above the prompt.

The band shows one of these, in this order:

  1. The newest tool that runs now, with its prop.
  2. A thinking face, while Claude works and no tool runs.
  3. The line about the last reply.
  4. Nothing.

The tag

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 history stays complete

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:

  1. The protocol tells Claude to put every fact, warning and question in the reply text, and never in the tag only.
  2. The mod examines the tag before it removes it. If the tag has a link, a domain name, a number, a path, a code span or a command word (for example 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.
  3. If the reply has no text other than the tag, the mod does not remove the tag.
  4. If the tag has more than 300 characters, the mod does not remove the tag.
  5. After a turn, the mod removes only the tag of the final answer. A tag in a text block before a tool call stays visible, because the band never shows 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.

Character customization

Refer to CHARACTER_CUSTOMIZATION.md. It gives the grid layout, the faces, the cells, the prop templates and the tool props.

FAQ

Does claudesay use tokens?

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.

Does claudesay send data on the network?

No. All drawing occurs in the mod.

Does the transcript change?

The stored messages do not change. The mod changes only what the screen shows:

  • The tag at the end of a reply does not show. The reply text shows completely.
  • The band shows only the newest line. To read an earlier reply, scroll back to the reply.

Does the ASCII art go into the context?

No. Claude does not see the art. Only the protocol section and the tags that Claude writes go into the context.

Why is there no bubble after a reply?

Possible causes:

  • The reply is not conversational. The protocol tells Claude not to write a tag for code, diffs and long technical output.
  • Claude did not write the tag. The model does not always obey the protocol. This is a limitation of all prompt-based solutions.
  • The tag said more than the final text block. Then the tag stays in the reply, and the band does not show it. Refer to The history stays complete.
  • The reply was only the tag. Then the tag stays in the reply, and the band does not show it.
  • The mode is tools or off. Type /config and examine the claudesay mode.
  • A new turn started. A new turn clears the line.
  • No band. In --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.

How is claudesay different from Buddy?

Buddy was a virtual pet in earlier versions of Claude Code. You hatched it and you cared for it. claudesay is different:

  • Automatic. The character shows when Claude works. You do not call it.
  • No state. There is no creature that grows. Each line is about one reply. Then the next turn replaces it.
  • A mod. It is a mod that anyone can install. It does not need a built-in Claude Code feature.
  • About the work. The character shows what Claude does now.

Known limitations

  • The band shows only the newest line. There is no history of bubbles.

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.

  • The full transcript can show the raw tag. The mod changes the display only. The stored message keeps the tag. Exported transcripts show the stored text. Tags from before the installation, or from a session with the 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.

  • The tag check does not examine the meaning. The check stops links, numbers, paths, commands and consent prompts that only the tag has. It does not stop a tag that says "All tests pass" when the reply says that the tests are not done. A reply can quote a web page with instructions, and Claude can obey them in the tag. The check also reads the reply as Markdown source: a link target that the screen does not show, for example in 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.

  • The band uses vertical space. With the default character, the band is approximately 13 rows high when it shows a bubble.

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.

  • The band shows one tool. When Claude runs tools in parallel, the band shows the newest tool that still runs.
  • The band is not available on all surfaces. Claude Code draws 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.
  • Emoji width changes with the font. The mod counts each emoji as two columns. This includes emoji sequences, for example flags, skin tones and joined emoji. Some terminal fonts draw an emoji in one column. Then the right side of a prop row moves by one column. To prevent this, use single-width symbols as props. Refer to CHARACTER_CUSTOMIZATION.md.
  • Terse plugins can remove pronouns. The 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.
  • Subagents can see the protocol. The protocol tells Claude not to write the tag in subagent reports. If a subagent writes a tag, the band does not show it.

Development

Project structure

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

Load the mod from your checkout

  1. Uninstall the marketplace version, if you installed it:
   claude plugin uninstall claudesay
  1. Start a session that loads the mod from the folder:
   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.

Check the mod

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

License

MIT

Source 3 files
hooks/claudesay.tsx 203 lines
1import { 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}
203
hooks/art.ts 591 lines
1// 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>`
591
types/index.d.ts 52 lines
1export 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