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 feethead, 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 leftright 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' }
| {