Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand Down
56 changes: 56 additions & 0 deletions agent/reactions.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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>
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down
119 changes: 119 additions & 0 deletions apps/desktop/src/components/chat/vibe-hearts.tsx
Original file line number Diff line number Diff line change
@@ -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
}}
/>
)
}
Loading
Loading