Merge pull request #62016 from NousResearch/bb/desktop-vibe-hearts

feat: vibe reactions — floating hearts on affection, across CLI/TUI/desktop
This commit is contained in:
brooklyn! 2026-07-10 16:14:27 -05:00 • committed by GitHub
commit 291eae63b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 687 additions and 19 deletions

View file

@ -302,6 +302,7 @@ def init_agent(
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
@ -535,6 +536,7 @@ def init_agent(
agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.reaction_callback = reaction_callback
agent.tool_gen_callback = tool_gen_callback

56
agent/reactions.py Normal file
View file

@ -0,0 +1,56 @@
"""Token-free detection of user *reactions* to the agent.
Currently the only reaction is ``vibe`` — an expression of affection or
gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart
emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**.
This is the single source of truth shared by every surface — the CLI pet, the
TUI heart, and the desktop floating hearts all react off the same signal,
delivered via ``AIAgent.reaction_callback`` (wired per interactive host).
Generalized on purpose: :func:`detect_reaction` returns a reaction *kind*
string, so new kinds (other emoji reactions, etc.) can be added here without
touching any caller. We match affection specifically — not general positive
sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do.
"""
from __future__ import annotations
import re
#: The affection/gratitude reaction — the only kind today.
VIBE = "vibe"
# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at
# the agent, heart emoji, and ``<3`` (but not the broken heart ``</3``).
_VIBE_RE = re.compile(
"|".join(
(
r"\bgood\s*bot\b",
r"\bi\s*(?:love|luv)\s*(?:you|u|ya)\b",
r"\b(?:love|luv)\s*(?:you|u|ya)\b",
r"\bily(?:sm)?\b",
r"\bthank\s*(?:you|u)\b",
r"\b(?:thanks|thx|tysm|ty)\b",
r"<3+", # <3, <33 … but not </3
# Hearts + affection faces (❤ ♥ 🥰 😍 😘 💕 💖 💗 💞 💛 💜 💚 💙 💓 💘 💝 🩷).
r"[\u2764\u2665"
r"\U0001F970\U0001F60D\U0001F618"
r"\U0001F495\U0001F496\U0001F497\U0001F49E"
r"\U0001F49B\U0001F49C\U0001F49A\U0001F499"
r"\U0001F493\U0001F498\U0001F49D\U0001FA77]",
)
),
re.IGNORECASE,
)
def detect_reaction(text: str | None) -> str | None:
"""Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``.
Pure, token-free, and safe to call on every user turn.
"""
if not text:
return None
return VIBE if _VIBE_RE.search(text) else None

View file

@ -319,6 +319,20 @@ def build_turn_context(
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
# and notify the host so it can play hearts. Token-free, never touches the
# conversation, and never fatal — a purely optional UI beat.
reaction_callback = getattr(agent, "reaction_callback", None)
if reaction_callback is not None:
try:
from agent.reactions import detect_reaction
kind = detect_reaction(original_user_message)
if kind:
reaction_callback(kind)
except Exception:
pass
if not agent.quiet_mode:
_print_preview = summarize_user_message_for_log(user_message)
agent._safe_print(

View file

@ -12,6 +12,7 @@ import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -30,6 +31,8 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$activeSessionId,
@ -297,6 +300,10 @@ export function ChatView({
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
const petActive = useStore($petActive)
const petOverlayActive = useStore($petOverlayActive)
const petPresent = petActive || petOverlayActive
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
@ -491,6 +498,18 @@ export function ChatView({
</div>
)}
{showChatBar && <ScrollToBottomButton />}
{/* Vibe hearts rise from the composer only when no pet is out (else
they play on the pet). Fired by the core `reaction` event. */}
{!petPresent && (
<HeartField
className="absolute inset-x-0 z-30"
config={COMPOSER_HEART_CONFIG}
style={{
top: 0,
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
}}
/>
)}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts'
import { PetBubble } from '@/components/pet/pet-bubble'
import { PetSprite } from '@/components/pet/pet-sprite'
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
@ -72,6 +73,8 @@ export function PetOverlayApp() {
const zoomAnchorRef = useRef<PetZoomAnchor | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
// Last mirrored reaction id — a bump means the main window fired a reaction.
const lastReactionRef = useRef<number | null>(null)
const ignoreRef = useRef(true)
const composerOpenRef = useRef(false)
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
@ -91,6 +94,19 @@ export function PetOverlayApp() {
setBusy(Boolean(payload.busy))
setAwaitingResponse(Boolean(payload.awaiting))
setUnread(Boolean(payload.unread))
// Play a reaction on a new id (ignore the first sync, which just primes it).
const reaction = payload.reaction ?? null
if (lastReactionRef.current === null) {
lastReactionRef.current = reaction?.id ?? 0
} else if (reaction && reaction.id > lastReactionRef.current) {
lastReactionRef.current = reaction.id
if (reaction.kind === 'vibe') {
playVibeHearts()
}
}
})
// Tell the main renderer we're mounted so it pushes the current frame (the
@ -416,6 +432,12 @@ export function PetOverlayApp() {
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
{/* Hearts on the popped-out pet — identical to in-window. */}
<PetHeartField
petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)}
petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)}
/>
{/* Mail icon: only when a finish landed while you were away. Jumps to
the app's most recent thread. Anchored to the sprite (kept inside
its box so the overlay's click-through hit-test still catches it);

View file

@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback } from 'react'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
import { burstVibeHearts } from '@/components/chat/vibe-hearts'
import { translateNow } from '@/i18n'
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
@ -264,6 +265,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// KawaiiSpinner), not real reasoning. The bottom-of-thread loading
// indicator already covers that UX, so we ignore these events to
// avoid a duplicative "Thinking" disclosure showing spinner text.
} else if (event.type === 'reaction') {
// Core-detected affection (ily / <3 / good bot) on the user's message.
// Play hearts only for the visible session so background turns stay quiet.
if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') {
burstVibeHearts()
}
} else if (event.type === 'reasoning.delta') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))

View file

@ -0,0 +1,119 @@
import { type CSSProperties } from 'react'
import {
createParticleEmitter,
ParticleField,
type ParticleFieldConfig
} from '@/components/particles/particle-field'
import { $petActive, flashPetActivity } from '@/store/pet'
import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay'
/**
* TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel
* heart glyph + pink). Placed two ways: rising from the composer when no pet is
* out, or from the pet when one is. Fired by the core `reaction` event (affection
* in a user message) via {@link burstVibeHearts}.
*/
// Light pink reads on both light and dark chat surfaces.
const HEART_COLORS = ['#ff9ec4'] as const
/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */
export const COMPOSER_HEART_CONFIG: Partial<ParticleFieldConfig> = {
count: 12,
size: [6, 13],
rise: [6.75, 15.75],
duration: [320, 700]
}
/** Pet placement: a compact puff off the pet. The field box spans feet→head, so
* rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */
const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = {
count: 10,
spawnWindowMs: 450,
size: [6, 12],
rise: [98, 118],
duration: [480, 880],
swayAmp: [5, 14],
bank: [6, 14]
}
// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`.
const HEART_GLYPH = (
<svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z"
fill="currentColor"
/>
</svg>
)
const emitter = createParticleEmitter()
/** Play hearts in THIS window (whichever HeartField is mounted). The overlay
* window calls this directly off the mirrored vibe signal. */
export const playVibeHearts = (count?: number) => emitter.burst(count)
/**
* Fire a vibe burst (from the core `reaction` event). Routes to where the
* affection should land:
* - pet popped out → forward to the overlay window + celebrate (mirrored)
* - pet in-window → play here (on the pet) + celebrate
* - no pet → play here (composer)
*/
export const burstVibeHearts = (count?: number) => {
const overlay = $petOverlayActive.get()
if (overlay || $petActive.get()) {
flashPetActivity({ celebrate: true })
}
if (overlay) {
forwardPetReaction('vibe')
} else {
playVibeHearts(count)
}
}
export interface HeartFieldProps {
config?: Partial<ParticleFieldConfig>
className?: string
style?: CSSProperties
}
/** Heart-skinned particle field. Caller supplies placement + a config preset. */
export function HeartField({ config, className, style }: HeartFieldProps) {
return (
<ParticleField
className={className}
colors={HEART_COLORS}
config={config}
emitter={emitter}
glyph={HEART_GLYPH}
style={style}
/>
)
}
/**
* Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the
* in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the
* rendered sprite dimensions (frame × scale).
*/
export function PetHeartField({ petW, petH }: { petW: number; petH: number }) {
return (
<HeartField
config={PET_HEART_CONFIG}
style={{
bottom: 0,
height: Math.max(96, petH),
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: Math.max(90, petW * 1.5),
zIndex: 2
}}
/>
)
}

View file

@ -0,0 +1,126 @@
/* ── Particle field ─────────────────────────────────────────────────────────
Reusable float-up emitter. Three nested layers, each owning ONE transform so
they never fight:
.particle — full-height track: vertical rise + fade (linear)
.particle__sway — horizontal weave + bank/tilt on its OWN period
(ease-in-out, alternating), so the path desyncs from
the rise and never repeats — the organic-motion trick
.particle__glyph — one-shot springy pop-in scale
The track is full-height and anchored at the field bottom, so the rise's
`translateY(-rise%)` measures against the field height, not the glyph's. All
tuning arrives as inline `--particle-*` custom properties. */
.particle-field {
pointer-events: none;
overflow: visible;
}
.particle {
position: absolute;
inset-block: 0;
left: var(--particle-left);
display: flex;
align-items: flex-end;
justify-content: center;
width: var(--particle-size);
margin-left: calc(var(--particle-size) / -2);
will-change: transform, opacity;
animation: particle-rise var(--particle-duration) linear var(--particle-delay) both;
}
.particle__sway {
display: block;
will-change: transform;
animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay)
infinite alternate;
}
.particle__glyph {
display: block;
width: var(--particle-size);
color: var(--particle-color);
will-change: transform;
animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both;
}
.particle__glyph > svg {
display: block;
width: 100%;
height: auto;
}
/* Rise: straight up the field, holds opacity through most of the climb, fades
near the top. */
@keyframes particle-rise {
0% {
opacity: 0;
transform: translate3d(0, 0, 0);
}
6% {
opacity: 1;
transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0);
}
70% {
opacity: 1;
transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0);
}
100% {
opacity: 0;
transform: translate3d(0, calc(var(--particle-rise) * -1%), 0);
}
}
/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a
leaf on a breeze. Runs on its own clock. */
@keyframes particle-sway {
from {
transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1));
}
to {
transform: translateX(var(--particle-sway)) rotate(var(--particle-bank));
}
}
/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */
@keyframes particle-pop {
0% {
transform: scale(0.3);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.particle {
inset-block: auto 0;
height: var(--particle-size);
animation: particle-flash 650ms ease-out var(--particle-delay) both;
}
.particle__sway,
.particle__glyph {
animation: none;
}
@keyframes particle-flash {
0% {
opacity: 0;
transform: translate3d(0, 0, 0) scale(0.6);
}
20% {
opacity: 1;
transform: translate3d(0, -0.5rem, 0) scale(1.1);
}
100% {
opacity: 0;
transform: translate3d(0, -1rem, 0) scale(1);
}
}
}

View file

@ -0,0 +1,204 @@
import './particle-field.css'
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
/**
* Reusable float-up particle emitter. It owns the motion (rise + organic sway +
* springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just
* hand it a `glyph` (any element using `currentColor`) and `colors`, then place
* it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use.
*/
type Range = readonly [min: number, max: number]
const rand = ([min, max]: Range) => min + Math.random() * (max - min)
/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */
const lerp = ([min, max]: Range, t: number) => min + (max - min) * t
export interface ParticleFieldConfig {
/** Particles per burst when `burst()` is called without a count. */
count: number
/** Window (ms) over which a burst releases its particles — small = one poof. */
spawnWindowMs: number
/** Glyph edge size (px), uniform. */
size: Range
/** Vertical travel before fade-out (% of field height), `life`-biased. */
rise: Range
/** Rise duration (ms), `life`-biased so short-lived particles also rise less. */
duration: Range
/** Sway amplitude each side of center (px), uniform. */
swayAmp: Range
/** Peak tilt into the sway (deg), uniform. */
bank: Range
/** Sway period (ms), uniform — independent of the rise so paths never repeat. */
swayDuration: Range
/** Cap on simultaneously-alive particles. */
maxAlive: number
}
export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = {
count: 12,
spawnWindowMs: 550,
size: [6, 13],
rise: [6.75, 15.75],
duration: [320, 700],
swayAmp: [9, 24],
bank: [7, 16],
swayDuration: [1300, 2800],
maxAlive: 200
}
export interface ParticleEmitter {
/** Fire a burst (defaults to the field's configured `count`). */
burst: (count?: number) => void
/** Internal: field subscription. */
subscribe: (fn: (count?: number) => void) => () => void
}
/** Create an emitter handle. `burst()` is safe to call from anywhere. */
export function createParticleEmitter(): ParticleEmitter {
const listeners = new Set<(count?: number) => void>()
return {
burst: count => listeners.forEach(fn => fn(count)),
subscribe: fn => {
listeners.add(fn)
return () => void listeners.delete(fn)
}
}
}
interface Particle {
id: number
leftPct: number
size: number
color: string
delayMs: number
durationMs: number
rise: number
swayAmp: number
bank: number
swayDurationMs: number
swayDelayMs: number
}
let nextId = 1
function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle {
// Short-lived particles fade out lower; a few live longer and rise higher.
const life = Math.random() ** 1.7
const swayDurationMs = Math.round(rand(cfg.swayDuration))
return {
id: nextId++,
// Spread edge to edge across the lane, not clustered near center.
leftPct: 4 + Math.random() * 92,
size: rand(cfg.size),
color: colors[Math.floor(Math.random() * colors.length)]!,
delayMs: Math.round(Math.random() * 120),
durationMs: Math.round(lerp(cfg.duration, life)),
rise: lerp(cfg.rise, life),
swayAmp: rand(cfg.swayAmp),
bank: rand(cfg.bank),
swayDurationMs,
// Negative delay drops each particle in mid-swing (desynced phases).
swayDelayMs: -Math.round(Math.random() * swayDurationMs)
}
}
const prefersReducedMotion = () =>
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
export interface ParticleFieldProps {
emitter: ParticleEmitter
/** Any element that paints with `currentColor` (SVG, glyph, …). */
glyph: ReactNode
colors: readonly string[]
config?: Partial<ParticleFieldConfig>
className?: string
style?: CSSProperties
}
export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) {
const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config])
const [particles, setParticles] = useState<Particle[]>([])
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
useEffect(() => {
const pool = timers.current
const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive))
// Release a burst across a tight window so it reads as one poof, each node
// with its own random birth time; reduced motion gets a single flash.
const onBurst = (count?: number) => {
const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count)))
if (prefersReducedMotion()) {
add()
return
}
for (let i = 0; i < n; i++) {
const timer = setTimeout(() => {
pool.delete(timer)
add()
}, Math.random() * cfg.spawnWindowMs)
pool.add(timer)
}
}
const unsubscribe = emitter.subscribe(onBurst)
return () => {
unsubscribe()
pool.forEach(clearTimeout)
pool.clear()
}
}, [cfg, colors, emitter])
const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id))
if (particles.length === 0) {
return null
}
return (
<div aria-hidden className={cn('particle-field', className)} style={style}>
{particles.map(p => (
<span
className="particle"
key={p.id}
// Retire on the RISE track only (sway is infinite, pop is shorter).
onAnimationEnd={e => {
if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') {
remove(p.id)
}
}}
style={
{
'--particle-left': `${p.leftPct}%`,
'--particle-size': `${p.size}px`,
'--particle-color': p.color,
'--particle-delay': `${p.delayMs}ms`,
'--particle-duration': `${p.durationMs}ms`,
'--particle-rise': p.rise,
'--particle-sway': `${p.swayAmp}px`,
'--particle-bank': `${p.bank}deg`,
'--particle-sway-duration': `${p.swayDurationMs}ms`,
'--particle-sway-delay': `${p.swayDelayMs}ms`
} as CSSProperties
}
>
<span className="particle__sway">
<span className="particle__glyph">{glyph}</span>
</span>
</span>
))}
</div>
)
}

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch'
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
import { PetHeartField } from '@/components/chat/vibe-hearts'
import { persistString, storedString } from '@/lib/storage'
import {
$petAtRest,
@ -447,6 +448,9 @@ export function FloatingPet() {
>
<PetSprite info={info} rowOverride={walk.row} />
</div>
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
burstVibeHearts's router. */}
<PetHeartField petH={petH} petW={petW} />
</div>
)
}

View file

@ -46,6 +46,8 @@ export interface PetOverlayStatePayload {
awaiting: boolean
/** Drives the overlay's mail icon: a finish landed while you were away. */
unread: boolean
/** Latest reaction — bumping its id forwards a burst to the overlay. */
reaction: PetReaction | null
}
export type PetOverlayControl =
@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false))
// Persist the in/out choice so a popped-out pet comes back popped out.
$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active))
/**
* Reaction signal forwarded to the popped-out overlay window via the state
* mirror below. `id` is a monotonic nonce so the overlay fires once per bump;
* `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose
* so future reactions (emoji, etc.) ride the same channel.
*/
export interface PetReaction {
id: number
kind: string
}
export const $petReaction = atom<PetReaction | null>(null)
export const forwardPetReaction = (kind: string) =>
$petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
function loadSavedBounds(): null | PetOverlayBounds {
try {
const raw = storedString(OVERLAY_BOUNDS_KEY)
@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload {
activity: $petActivity.get(),
busy: $busy.get(),
awaiting: $awaitingResponse.get(),
unread: $petUnread.get()
unread: $petUnread.get(),
reaction: $petReaction.get()
}
}
@ -165,7 +184,8 @@ function openOverlay(request: PetOverlayOpenRequest): void {
$petActivity.subscribe(pushNow),
$busy.subscribe(pushNow),
$awaitingResponse.subscribe(pushNow),
$petUnread.subscribe(pushNow)
$petUnread.subscribe(pushNow),
$petReaction.subscribe(pushNow)
]
}

View file

@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState {
export const $petInfo = atom<PetInfo>({ enabled: false })
export const $petActivity = atom<PetActivity>({})
/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */
export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64))
/**
* Profile the pet RPCs should resolve against. Pets are per-profile — the active
* pet (`display.pet.*`) and the installed sprites live under each profile's

6
cli.py
View file

@ -4862,6 +4862,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._pet_event = state
self._pet_event_until = time.monotonic() + secs
def _on_reaction(self, kind: str) -> None:
"""User affection (ily / <3 / good bot), core-detected — the pet's share
of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate."""
if kind == "vibe":
self._pet_flash("jump")
def _pet_react_turn_end(self) -> None:
"""Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave."""
if not self._pet_enabled:

View file

@ -390,6 +390,7 @@ class CLIAgentSetupMixin:
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
notice_callback=self._on_notice,
notice_clear_callback=self._on_notice_clear,
reaction_callback=self._on_reaction,
)
# Store reference for atexit memory provider shutdown.
# NOTE: this MUST write to the ``cli`` module's global, not a

View file

@ -458,6 +458,7 @@ class AIAgent:
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
@ -533,6 +534,7 @@ class AIAgent:
notice_callback=notice_callback,
notice_clear_callback=notice_clear_callback,
event_callback=event_callback,
reaction_callback=reaction_callback,
max_tokens=max_tokens,
reasoning_config=reasoning_config,
service_tier=service_tier,

View file

@ -0,0 +1,56 @@
"""Behavior tests for the token-free reaction detector."""
import pytest
from agent.reactions import VIBE, detect_reaction
@pytest.mark.parametrize(
"text",
[
"good bot",
"Good Bot!",
"ily",
"ilysm",
"i love you",
"love you",
"love u",
"luv ya",
"thanks",
"thank you",
"thx",
"ty",
"tysm",
"you're the best <3",
"here you go <33",
"❤️",
"🥰 amazing",
"sending 💖",
"great job, thank you so much!",
],
)
def test_affection_fires_vibe(text):
assert detect_reaction(text) == VIBE
@pytest.mark.parametrize(
"text",
[
"",
None,
"run the tests",
"this is great", # positive sentiment, NOT affection — must not fire
"awesome work on the refactor",
"it's broken </3", # broken heart is not affection
"the ferry departs at 3", # 'ty' must be word-bounded, not match 'ferTY'-like
"commit the changes",
],
)
def test_neutral_or_negative_does_not_fire(text):
assert detect_reaction(text) is None
def test_case_insensitive_invariant():
# Casing must never change the classification.
for text in ("ILY", "iLy", "ily"):
assert detect_reaction(text) == detect_reaction("ily")

View file

@ -3870,6 +3870,9 @@ def _agent_cbs(sid: str) -> dict:
"tool_gen_callback": lambda name: _tool_progress_enabled(sid)
and _emit("tool.generating", sid, {"name": name}),
"thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}),
# Affection reaction (ily / <3 / good bot) → hearts. Core-detected, so
# the TUI heart and desktop floating hearts share one signal.
"reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}),
"reasoning_callback": lambda text: _emit(
"reasoning.delta",
sid,

View file

@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial<SubmitPromptDeps> = {}): Subm
enqueue: vi.fn(),
expand: (t: string) => t,
gw,
maybeGoodVibes: vi.fn(),
setLastUserMsg: vi.fn(),
sys: vi.fn(),
...over

View file

@ -20,7 +20,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { getOverlayState, patchOverlayState } from './overlayStore.js'
import { flashPet } from './petFlashStore.js'
import { flashGoodVibes, flashPet } from './petFlashStore.js'
import { turnController } from './turnController.js'
import { getTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
@ -717,6 +717,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'reaction':
// Core-detected affection (ily / <3 / good bot): flash the ♥ and let the
// pet celebrate. Same signal drives the desktop's floating hearts.
flashGoodVibes()
flashPet('jump')
return
case 'tool.start':
turnController.recordTodos(ev.payload.todos)
turnController.recordToolStart(

View file

@ -14,6 +14,13 @@ export const $petFlash = atom<PetFlash | null>(null)
export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms })
// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by
// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's
// share of the same signal that plays the desktop's floating hearts.
export const $goodVibesTick = atom(0)
export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1)
// The floating pet's footprint, or null when no pet is shown. The transcript
// keeps its text clear of the pet responsively: on wide terminals it reserves a
// right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it

View file

@ -15,7 +15,6 @@ export interface SubmitPromptDeps {
enqueue: (text: string) => void
expand: (text: string) => string
gw: GatewayClient
maybeGoodVibes: (text: string) => void
setLastUserMsg: (value: string) => void
sys: (text: string) => void
}
@ -61,7 +60,6 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa
}
turnController.clearStatusTimer()
deps.maybeGoodVibes(submitText)
deps.setLastUserMsg(text)
if (show) {

View file

@ -37,6 +37,7 @@ import { planGatewayRecovery } from './gatewayRecovery.js'
import { getInputSelection } from './inputSelectionStore.js'
import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { $goodVibesTick } from './petFlashStore.js'
import { scrollWithSelectionBy } from './scroll.js'
import { turnController } from './turnController.js'
import { patchTurnState, useTurnSelector } from './turnStore.js'
@ -48,7 +49,6 @@ import { useLongRunToolCharms } from './useLongRunToolCharms.js'
import { useSessionLifecycle } from './useSessionLifecycle.js'
import { useSubmission } from './useSubmission.js'
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
const BRACKET_PASTE_ON = '\x1b[?2004h'
const BRACKET_PASTE_OFF = '\x1b[?2004l'
const MAX_HEIGHT_CACHE_BUCKETS = 12
@ -185,7 +185,8 @@ export function useMainApp(gw: GatewayClient) {
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
const [goodVibesTick, setGoodVibesTick] = useState(0)
// Bumped by the gateway `reaction` event (core-detected affection).
const goodVibesTick = useStore($goodVibesTick)
const [bellOnComplete, setBellOnComplete] = useState(false)
const ui = useStore($uiState)
@ -445,12 +446,6 @@ export function useMainApp(gw: GatewayClient) {
[sys]
)
const maybeGoodVibes = useCallback((text: string) => {
if (GOOD_VIBES_RE.test(text)) {
setGoodVibesTick(v => v + 1)
}
}, [])
const rpc: GatewayRpc = useCallback(
async <T extends Record<string, any> = Record<string, any>>(
method: string,
@ -690,7 +685,6 @@ export function useMainApp(gw: GatewayClient) {
composerRefs,
composerState,
gw,
maybeGoodVibes,
setLastUserMsg,
slashRef,
submitRef,

View file

@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
composerRefs,
composerState,
gw,
maybeGoodVibes,
setLastUserMsg,
slashRef,
submitRef,
@ -87,14 +86,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
enqueue: composerActions.enqueue,
expand,
gw,
maybeGoodVibes,
setLastUserMsg,
sys
},
showUserMessage
)
},
[appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys]
[appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys]
)
const shellExec = useCallback(
@ -362,7 +360,6 @@ export interface UseSubmissionOptions {
composerRefs: ComposerRefs
composerState: ComposerState
gw: GatewayClient
maybeGoodVibes: (text: string) => void
setLastUserMsg: (value: string) => void
slashRef: MutableRefObject<(cmd: string) => boolean>
submitRef: MutableRefObject<(value: string) => void>

View file

@ -618,6 +618,7 @@ export type GatewayEvent =
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
| { payload?: { kind?: string }; session_id?: string; type: 'reaction' }
| { payload?: undefined; session_id?: string; type: 'message.start' }
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
| {