import { feature } from 'bun:bundle' import { useEffect, useRef } from 'react' import { getTerminalFocusState, subscribeTerminalFocus, } from '../ink/terminal-focus-state.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { generateAwaySummary } from '../services/awaySummary.js' import type { Message } from '../types/message.js' import { createAwaySummaryMessage } from '../utils/messages.js' const BLUR_DELAY_MS = 5 * 60_000 type SetMessages = (updater: (prev: Message[]) => Message[]) => void function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]! if (m.type === 'user' && !m.isMeta && !m.isCompactSummary) return false if (m.type === 'system' && m.subtype === 'away_summary') return true } return false } /** * Appends a "while you were away" summary message after the terminal has been * blurred for 5 minutes. Fires only when (a) 5min since blur, (b) no turn in * progress, and (c) no existing away_summary since the last user message. * * Focus state 'unknown' (terminal doesn't support DECSET 1004) is a no-op. */ export function useAwaySummary( messages: readonly Message[], setMessages: SetMessages, isLoading: boolean, ): void { const timerRef = useRef | null>(null) const abortRef = useRef(null) const messagesRef = useRef(messages) const isLoadingRef = useRef(isLoading) const pendingRef = useRef(false) const generateRef = useRef<(() => Promise) | null>(null) messagesRef.current = messages isLoadingRef.current = isLoading // 3P default: false const gbEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_sedge_lantern', false, ) useEffect(() => { if (!feature('AWAY_SUMMARY')) return if (!gbEnabled) return function clearTimer(): void { if (timerRef.current !== null) { clearTimeout(timerRef.current) timerRef.current = null } } function abortInFlight(): void { abortRef.current?.abort() abortRef.current = null } async function generate(): Promise { pendingRef.current = false if (hasSummarySinceLastUserTurn(messagesRef.current)) return abortInFlight() const controller = new AbortController() abortRef.current = controller const text = await generateAwaySummary( messagesRef.current, controller.signal, ) if (controller.signal.aborted || text === null) return setMessages(prev => [...prev, createAwaySummaryMessage(text)]) } function onBlurTimerFire(): void { timerRef.current = null if (isLoadingRef.current) { pendingRef.current = true return } void generate() } function onFocusChange(): void { const state = getTerminalFocusState() if (state === 'blurred') { clearTimer() timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS) } else if (state === 'focused') { clearTimer() abortInFlight() pendingRef.current = false } // 'unknown' → no-op } const unsubscribe = subscribeTerminalFocus(onFocusChange) // Handle the case where we're already blurred when the effect mounts onFocusChange() generateRef.current = generate return () => { unsubscribe() clearTimer() abortInFlight() generateRef.current = null } }, [gbEnabled, setMessages]) // Timer fired mid-turn → fire when turn ends (if still blurred) useEffect(() => { if (isLoading) return if (!pendingRef.current) return if (getTerminalFocusState() !== 'blurred') return void generateRef.current?.() }, [isLoading]) }