← All Posts

I Built a Floating Anime Cat Mascot for Claude Code — Here's What I Learned About Multi-Session Terminal Workflows

5 min readMarch 28, 2026
claude-codehooksmacosswiftuideveloper-toolsworkflow
Floating Anime Cat Mascot
Floating Anime Cat Mascot

I run 3-4 Claude Code sessions at once. I needed to know which one just finished — without reading a single line of output.

That sounds like a small problem until you've lived it. Four terminal windows tiled across a 27-inch monitor, each running a different task — one building a feature, one writing a blog post, one running a security audit, one deploying. They all look the same. Same dark background. Same monospace font. Same blinking cursor. When one finishes, you have to read the output to figure out what it was doing.

I solved this with a floating anime cat. Let me explain.


The Multi-Session Problem

Claude Code is session-based. You open a terminal, type claude, and you're in a conversation. Need to work on something else at the same time? Open another terminal. That's the mental model, and it works great until you're juggling three or four of them simultaneously.

The problems show up fast:

ProblemWhat happens
No visual identityEvery session window looks identical — same prompt, same font, same colors
No audio feedbackA session finishes while you're focused on another one — you don't notice for minutes
No state at a glanceIs that session thinking? Done? Waiting for input? Error? You have to read the output to know
Context switching costSwitching between sessions means re-reading output to remember what each one was doing

I needed three things: a way to see which session was which, a way to hear when something important happened, and a way to know each session's state without reading a line of text.


The Built-In Tools: /rename and /color

Before building anything custom, I leaned on what Claude Code already provides.

/rename lets you give a session a human-readable name. Instead of staring at four identical terminals, I name them by what they're doing:

/rename blog-post
/rename security-audit
/rename deploy
/rename feature-xyz

/color assigns a color to the session's accent. Combined with /rename, you get a terminal that says "I am the blog-post session and my color is blue" without any custom tooling:

/color blue
/color red
/color green
/color orange

These two commands alone were a quality-of-life improvement. But the information only lives inside each terminal window. When you're looking at your desktop from a distance, or you're focused on a different app entirely, you can't see any of it.

I needed something that floated above everything else.


Sound Notifications via Hooks

Before the visual solution, I tackled the audio problem. Claude Code has a hook system — you can trigger shell commands on specific lifecycle events. I added three hooks to ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Glass.aiff &"
          }
        ]
      }
    ],
    "StopFailure": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Basso.aiff &"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Funk.aiff &"
          }
        ]
      }
    ]
  }
}

Three distinct sounds. Glass means "done successfully." Basso means "something failed." Funk means "needs your attention."

afplay is a macOS built-in — no dependencies, no install, works immediately. The & at the end runs it in the background so the hook doesn't block Claude Code. These sound files ship with every Mac in /System/Library/Sounds/.

The impact was immediate. I'd be writing code in one terminal and hear the Glass chime from another. Without looking, I knew: a session just finished successfully. The Basso thud? Something broke — go check. The Funk chirp? Claude Code is asking me a question.

Three sounds. Zero visual context switching. That alone is worth the 30 seconds it takes to add these hooks.


Building the SwiftUI Mascot

Sound told me when something happened. I still needed to see what at a glance — across all sessions, without switching windows. So I built a floating widget.

The idea: a transparent panel that sits in the bottom-right corner of my screen, always on top of everything, showing one anime cat per active Claude Code session. Each cat reflects its session's state through its expression — focused when thinking, happy when done, frustrated on error, sleepy when idle.

I wrote it in SwiftUI because it's the most natural way to build a macOS floating panel with minimal code. The core is an NSPanel configured to float above all windows:

panel = NSPanel(
    contentRect: NSRect(x: 0, y: 0, width: 120, height: 600),
    styleMask: [.borderless, .nonactivatingPanel],
    backing: .buffered,
    defer: false
)
panel.level = .floating
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.isMovableByWindowBackground = true

The key flags: .nonactivatingPanel means clicking the mascot doesn't steal focus from your terminal. .canJoinAllSpaces means it follows you across macOS desktops. .floating means it sits above normal windows but below system UI.

Mascot State Machine
thinking
Focused expression + indigo glow ring
done
Happy expression + green glow ring + Glass sound
error
Frustrated expression + red glow ring + Basso sound
waiting
Neutral expression + orange glow ring + Funk sound
idle
Sleepy expression + gray glow ring

I started with five cats, each with a Japanese name and a theme color: Sakura, Kuro, Mochi, Tora, and Sora. Each cat has five emotion sprites — neutral, focused, happy, frustrated, sleepy — mapped to the session states. When a session is thinking, its cat looks focused. When it finishes, the cat smiles. When there's an error, it looks frustrated. When idle for a while, it falls asleep.

The character system is designed to be extensible. I've since added a robot collection (Bolt, Nova, Titan, Pixel, Zero) and a Chiikawa collection. Each one is just a MascotCharacter struct with an ID, a name, a collection folder, and a theme color:

struct MascotCharacter: Identifiable {
    let id: String
    let name: String
    let subtitle: String
    let collection: String
    let themeColor: Color

    func imagePath(for emotion: CatEmotion) -> String {
        let home = FileManager.default.homeDirectoryForCurrentUser.path
        return "\(home)/.claude/mascot/media/\(collection)/\(id)/\(emotion.rawValue).png"
    }
}

The session label (from /rename) and color (from /color) render beneath each cat. When I /color red a session, the glow ring around that cat turns red. When I /rename deploy, the label beneath the cat says "deploy." At a glance, from across the room, I can see: three cats on screen. One is happy (done), one looks focused (thinking), one looks annoyed (error). The red one is the deploy. The blue one is the blog post. I don't need to read a single terminal.


The Green-Screen Trick

The cats needed to look good on a transparent floating panel. That meant every sprite needed a fully transparent background — no white edges, no artifacts, no halos around the character.

My first approach was generating characters on white backgrounds and then removing the white. This works fine for colorful characters. It does not work for Mochi, the white cat. Removing white from a white cat removes the cat.

The fix was the same trick film studios have used for decades: green screen.

Instead of generating characters on white, I prompted the AI image generator with an explicit instruction:

solid bright green background #00FF00

Pure green (#00FF00) is the standard chroma key color because it's the color least likely to appear in a character's design. With a green background, removing the background becomes trivial — you're targeting a single, specific color that isn't present in the subject.

For the chroma key removal itself, I wrote a simple Python script using Pillow:

def remove_white_bg(path, threshold=230):
    img = Image.open(path).convert("RGBA")
    data = np.array(img)
    white_mask = np.all(data[:, :, :3] > threshold, axis=2)
    data[white_mask, 3] = 0
    # Trim, pad, resize to 256x256
    result = Image.fromarray(data)
    # ...
    square.save(path)

For white/light characters, swap the mask to target green instead of white. The critical lesson I learned the hard way: never use aggressive global threshold removal. A naive "remove everything above brightness X" approach will destroy internal white pixels — eyes, teeth, highlights. Instead, flood-fill from the borders only, or target a specific chroma key color. I ended up with two approaches depending on the character:

Character typeBackgroundRemoval method
Colored characters (Kuro, Tora, Sora)White #FFFFFFThreshold removal + edge anti-aliasing
White/light characters (Sakura, Mochi)Green #00FF00Chroma key removal

Another lesson: don't try to use character sheets (sprite grids). AI image generators are bad at consistent grid spacing, and cropping individual frames from a sheet introduces alignment errors. Generate each emotion as a separate image with a shared style prompt for consistency. Five separate generations per character is more work than one sheet, but the results are dramatically better.


macOS Sequoia Gotchas

Building a native macOS app without an Apple Developer certificate turns out to be an adventure on Sequoia.

The first problem: Gatekeeper quarantine. When you compile a Swift binary with swiftc and try to run it, macOS flags it as "from an unidentified developer" and blocks execution. The fix:

# Compile
swiftc -O -parse-as-library -framework Cocoa -framework SwiftUI \
  ClaudeMascot.swift MascotMenu.swift -o ClaudeMascot

# Remove quarantine flag
xattr -cr ClaudeMascot

# Ad-hoc codesign (no Apple cert needed)
codesign --force --deep --sign - ClaudeMascot

The xattr -cr strips the quarantine extended attribute. The codesign --force --deep --sign - applies an ad-hoc signature — it doesn't verify identity, but it satisfies Gatekeeper enough to let the binary run.

The second problem: NSStatusItem doesn't work for unsigned apps on Sequoia 15.6. I originally planned to put the mascot controls in the menu bar. That failed silently — the status item just never appeared. No error, no crash, just nothing in the menu bar. After an hour of debugging, I discovered this is a known restriction for unsigned apps. The workaround: a right-click context menu on the mascot itself. Right-click any cat to remove its session, change the mascot size, hide the panel, or quit the app entirely.

The third problem: auto-launch on terminal open. I wanted the mascot to start automatically when I open a terminal, but only if it wasn't already running. This goes in ~/.zshrc:

pgrep -x ClaudeMascot > /dev/null 2>&1 || ~/.claude/mascot/ClaudeMascot &>/dev/null &

pgrep -x checks for an exact process name match. If the mascot isn't running, launch it in the background. If it is, do nothing. Simple, reliable, no LaunchAgent complexity.


Session State Sync

The most interesting engineering problem was keeping the mascot in sync with Claude Code sessions. The data flows through two paths:

Data Flow: Hooks + Transcripts
Path 1: Hooks (fast)
PreToolUse hook fires
|
update-state.sh reads stdin JSON payload
|
Extracts session_id, cwd, transcript_path
|
Writes to state.json (atomic file replace)
Latency: instant (on every hook event)
Path 2: Transcripts (rich)
SwiftUI app polls every 2 seconds
|
Reads .jsonl transcript files
|
Parses /rename and /color commands
|
Merges labels + colors into state.json
Latency: up to 2 seconds (polling)

Path 1 is the hooks. When Claude Code triggers a lifecycle event (PreToolUse, Stop, StopFailure, Notification), the hook calls update-state.sh, which receives a JSON payload on stdin containing the session ID, working directory, and transcript path. The script parses this with an embedded Python script, updates state.json with the new state, and exits. The SwiftUI app polls state.json every 0.5 seconds and updates the UI.

Path 2 is transcript scanning. Claude Code writes session transcripts as .jsonl files. When you type /rename blog-post, it writes a JSON entry to the transcript. The SwiftUI app has a TranscriptScanner class that reads these files every 2 seconds, looking for /rename and /color entries. When it finds them, it merges the label and color back into state.json, and the main poll loop picks up the changes on the next tick.

The state file uses atomic writes (os.replace() on the Python side, replaceItemAt on the Swift side) and file locking (fcntl.flock) to prevent corruption when multiple sessions write simultaneously. Dead sessions are pruned automatically — any session that hasn't updated in 8 hours gets removed.

The priority system handles edge cases. A "waiting" event that arrives right after a "done" event (within 5 seconds) gets downgraded — it's just an end-of-response notification, not a real wait state. This prevents the cat from flickering between happy and neutral at the end of every response.


What I'd Do Differently

Use launchd instead of zshrc for auto-start. The pgrep check in .zshrc works, but it runs on every new terminal tab. A proper LaunchAgent plist would start the mascot at login and keep it running — cleaner, more reliable, and no unnecessary process checks.

Build a proper .app bundle from the start. I compiled raw .swift files with swiftc for speed. It works, but you lose things like proper Info.plist metadata, icon support, and Gatekeeper behaves better with a real .app bundle. I eventually wrapped it in a minimal bundle, but starting there would have saved the Sequoia debugging.

Invest in a proper character pipeline earlier. My first batch of character sprites took multiple iterations of prompt tweaking, background removal, and manual cleanup. If I'd established the green-screen convention and the per-emotion generation pattern from the beginning, adding new character collections would have been a 10-minute task instead of an hour of trial and error.

Add a keyboard shortcut for show/hide. Currently the toggle is through a mascot-toggle.sh script or the right-click menu. A global hotkey (via a SwiftUI NSEvent.addGlobalMonitorForEvents handler) would be more ergonomic when you want to temporarily clear screen space for a demo or screenshot.


The Aha Moment

The thing that surprised me most about this project isn't the mascot itself. It's how much mileage you can get from combining Claude Code's built-in features with simple shell hooks.

/rename and /color give you session identity. Hooks give you lifecycle events. afplay gives you audio feedback. A 150-line state file gives you cross-session coordination. And a SwiftUI floating panel ties it all together into something you can read at a glance from across the room.

None of these pieces are individually impressive. /rename is a one-word command. afplay is a one-line hook. The state file is a JSON blob. But composed together, they turn Claude Code from a tool you interact with one session at a time into a system you can orchestrate across multiple sessions simultaneously.

The cat is fun. The workflow is the real product.

If you're running multiple Claude Code sessions and haven't set up sound hooks yet, start there. Three lines in settings.json, three distinct sounds, zero switching cost. That alone is a meaningful upgrade. And if you want to go further — a floating status widget, session-aware characters, transcript-driven sync — the building blocks are all there. Claude Code's hook system and transcript format are surprisingly composable once you start treating them as APIs rather than internal implementation details.

The source code lives at github.com/carlfung1003/claude-code-mascot. It's a weekend project, not a polished product. But it solved a real problem, and it makes me smile every time Sakura gives me a happy face because the deploy succeeded.