refactor(tui): drive the vibe heart from the core reaction event

Replace the client-side GOOD_VIBES_RE detection with the backend `reaction`
event: on it, flash the status-bar heart and the pet's celebrate pose. Detection
now lives once in the core, so the TUI, CLI, and desktop stay in sync.
This commit is contained in:
Brooklyn Nicholson 2026-07-10 05:42:08 -05:00
parent 0e2adf9dad
commit fbefb5c075
7 changed files with 21 additions and 17 deletions

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' }
| {