source dump of claude code
at main 125 lines 3.8 kB view raw
1import { feature } from 'bun:bundle' 2import { useEffect, useRef } from 'react' 3import { 4 getTerminalFocusState, 5 subscribeTerminalFocus, 6} from '../ink/terminal-focus-state.js' 7import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 8import { generateAwaySummary } from '../services/awaySummary.js' 9import type { Message } from '../types/message.js' 10import { createAwaySummaryMessage } from '../utils/messages.js' 11 12const BLUR_DELAY_MS = 5 * 60_000 13 14type SetMessages = (updater: (prev: Message[]) => Message[]) => void 15 16function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean { 17 for (let i = messages.length - 1; i >= 0; i--) { 18 const m = messages[i]! 19 if (m.type === 'user' && !m.isMeta && !m.isCompactSummary) return false 20 if (m.type === 'system' && m.subtype === 'away_summary') return true 21 } 22 return false 23} 24 25/** 26 * Appends a "while you were away" summary message after the terminal has been 27 * blurred for 5 minutes. Fires only when (a) 5min since blur, (b) no turn in 28 * progress, and (c) no existing away_summary since the last user message. 29 * 30 * Focus state 'unknown' (terminal doesn't support DECSET 1004) is a no-op. 31 */ 32export function useAwaySummary( 33 messages: readonly Message[], 34 setMessages: SetMessages, 35 isLoading: boolean, 36): void { 37 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 38 const abortRef = useRef<AbortController | null>(null) 39 const messagesRef = useRef(messages) 40 const isLoadingRef = useRef(isLoading) 41 const pendingRef = useRef(false) 42 const generateRef = useRef<(() => Promise<void>) | null>(null) 43 44 messagesRef.current = messages 45 isLoadingRef.current = isLoading 46 47 // 3P default: false 48 const gbEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 49 'tengu_sedge_lantern', 50 false, 51 ) 52 53 useEffect(() => { 54 if (!feature('AWAY_SUMMARY')) return 55 if (!gbEnabled) return 56 57 function clearTimer(): void { 58 if (timerRef.current !== null) { 59 clearTimeout(timerRef.current) 60 timerRef.current = null 61 } 62 } 63 64 function abortInFlight(): void { 65 abortRef.current?.abort() 66 abortRef.current = null 67 } 68 69 async function generate(): Promise<void> { 70 pendingRef.current = false 71 if (hasSummarySinceLastUserTurn(messagesRef.current)) return 72 abortInFlight() 73 const controller = new AbortController() 74 abortRef.current = controller 75 const text = await generateAwaySummary( 76 messagesRef.current, 77 controller.signal, 78 ) 79 if (controller.signal.aborted || text === null) return 80 setMessages(prev => [...prev, createAwaySummaryMessage(text)]) 81 } 82 83 function onBlurTimerFire(): void { 84 timerRef.current = null 85 if (isLoadingRef.current) { 86 pendingRef.current = true 87 return 88 } 89 void generate() 90 } 91 92 function onFocusChange(): void { 93 const state = getTerminalFocusState() 94 if (state === 'blurred') { 95 clearTimer() 96 timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS) 97 } else if (state === 'focused') { 98 clearTimer() 99 abortInFlight() 100 pendingRef.current = false 101 } 102 // 'unknown' → no-op 103 } 104 105 const unsubscribe = subscribeTerminalFocus(onFocusChange) 106 // Handle the case where we're already blurred when the effect mounts 107 onFocusChange() 108 generateRef.current = generate 109 110 return () => { 111 unsubscribe() 112 clearTimer() 113 abortInFlight() 114 generateRef.current = null 115 } 116 }, [gbEnabled, setMessages]) 117 118 // Timer fired mid-turn → fire when turn ends (if still blurred) 119 useEffect(() => { 120 if (isLoading) return 121 if (!pendingRef.current) return 122 if (getTerminalFocusState() !== 'blurred') return 123 void generateRef.current?.() 124 }, [isLoading]) 125}