Skip to content

Add a companion

qt314 is the kaomoji beside what the web agent says. She never decides her own mood — the app does, from what actually happened — so she is exactly as honest as the data next to her. The whole thing is one React file and a few lines of CSS. Copy both; there is no package.

(^‿^) calm (・・?) thinking (>'-')> working \(^▽^)/ bright (¬_¬) squint

Save as companion.tsx:

'use client'
import { useEffect, useState } from 'react'
export type Faces = Record<string, readonly string[]>
export function faceFor(faces: Faces, mood: string, step: number): string {
const set = faces[mood] ?? Object.values(faces)[0] ?? ['']
return set[Math.abs(step) % set.length]!
}
export type CompanionProps = {
mood: string
step: number
faces: Faces
/** Idle face; omit to never blink. */
blink?: string
/** Which mood blinks. Default: 'calm'. */
blinkOn?: string
/** Frozen: default face, no blink, no glitch, dimmed. */
still?: boolean
className?: string
}
export function Companion({ mood, step, faces, blink, blinkOn = 'calm', still = false, className = '' }: CompanionProps) {
const [blinking, setBlinking] = useState(false)
const canBlink = blink !== undefined && mood === blinkOn && !still
useEffect(() => {
if (!canBlink) return
let timeout: ReturnType<typeof setTimeout> | undefined
const interval = setInterval(() => {
setBlinking(true)
timeout = setTimeout(() => setBlinking(false), 140)
}, 3800 + Math.floor(Math.random() * 2600))
return () => {
clearInterval(interval)
if (timeout) clearTimeout(timeout)
}
}, [canBlink])
if (still) {
return (
<span className={`companion companion-still ${className}`} aria-hidden="true">
{faceFor(faces, blinkOn, 0)}
</span>
)
}
const face = canBlink && blinking ? blink : faceFor(faces, mood, step)
return (
<span className={`companion companion-${mood} glitch ${className}`} data-text={face} aria-hidden="true">
{face}
</span>
)
}

And companion.css — adjust the colors; --neon is the only variable that matters:

.companion { --neon: #f0b35e; color: var(--neon); display: inline-block; font-family: ui-monospace, monospace; position: relative; text-shadow: 0 0 .55rem color-mix(in srgb, var(--neon) 55%, transparent); transition: color .2s; white-space: nowrap; }
.companion-thinking, .companion-working { --neon: #6ee7ff; }
.companion-bright { --neon: #ffb454; }
.companion-squint { --neon: #ff5c5c; }
.companion-still { color: #6b6f78; text-shadow: none; }
.companion.glitch::before, .companion.glitch::after { content: attr(data-text); inset: 0; opacity: 0; pointer-events: none; position: absolute; }
.companion.glitch::before { animation: glitch-a 7s steps(1) infinite; color: #6ee7ff; }
.companion.glitch::after { animation: glitch-b 7s steps(1) infinite; color: #ffb454; }
.companion-working.glitch::before, .companion-working.glitch::after { animation-duration: 1.6s; }
@keyframes glitch-a {
0%, 100% { opacity: 0; }
2% { clip-path: inset(20% 0 55% 0); opacity: 1; transform: translate(-.2rem, 0); }
4% { clip-path: inset(60% 0 10% 0); transform: translate(.15rem, .05rem); }
6% { opacity: 0; }
40% { clip-path: inset(5% 0 80% 0); opacity: 1; transform: translate(.15rem, 0); }
42% { opacity: 0; }
}
@keyframes glitch-b {
0%, 100% { opacity: 0; }
2% { clip-path: inset(50% 0 20% 0); opacity: 1; transform: translate(.2rem, 0); }
4% { clip-path: inset(10% 0 70% 0); transform: translate(-.15rem, -.05rem); }
6% { opacity: 0; }
40% { clip-path: inset(65% 0 5% 0); opacity: 1; transform: translate(-.15rem, 0); }
42% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .companion.glitch::before, .companion.glitch::after { animation: none; } }

faces is a map from your mood names to arrays of strings. Any strings.

import { Companion } from './companion'
import './companion.css'
const FACES = {
calm: ['(^‿^)', '(・‿・)', '(´▽`)', '(◕‿◕)'],
thinking: ['(・・?)', '( ̄ω ̄;)', '(˘︹˘ )'],
working: ["(>'-')>", "<('-'<)", "^('-')^", "v('-')v"], // Kirby, one step per event
bright: ['\\(^▽^)/', '(★‿★)', '(⌒▽⌒)☆'],
squint: ['(¬_¬)', '(>_<)', '(x_x)'],
}
<Companion mood={mood} step={events} faces={FACES} blink="(-‿-)" />

Other sets that work unchanged:

spinner 🏃 emoji 😑 emoji, held

// a spinner
const FACES = { working: ['', '', '', '', '', '', '', ''], calm: ['·'] }
// emoji
const FACES = { calm: ['🙂', '🙃'], working: ['🏃', '🏃‍♀️'], squint: ['😑'] }

This is the entire API: step indexes into the current mood’s array.

  • Increment it on each event (a tool call, a chunk, a tick) and she steps through the set — that is the dance and the spinner.
  • Hold it constant and she keeps one face. The web agent passes the row index, so a line keeps the face it was born with.

mood is whatever you decide it is. Derive it from real state — a pending request, an error, a confirmed transaction — not from a model’s guess. She should only ever look as happy as things actually are.

propwhat
moodkey into faces
steppicks the face within the mood
facesyour map
blinkidle face; omit and she never blinks
blinkOnwhich mood blinks (default 'calm')
stillfrozen and dimmed — for lines that are no longer live

Colors come from .companion-<mood> classes, so a new mood is one CSS line.