import { useEffect, useRef } from 'react'; import { matchCodingCliProviderFromCommand } from '../../domain/codingCliProviderMatch'; import { createCodingCliOutputScanner, type CodingCliOutputScanner, } from '../../domain/codingCliOutputDetect'; import type { CodingCliProviderId } from '../../domain/codingCliProviders'; import { inferCodingCliProviderFromTitleSignals, shouldClearCodingCliProviderForTitle, } from '../../domain/codingCliTitleParse'; import type { DynamicTabTitleMode } from '../../domain/models'; import { resolveCodingCliProviderIconUpdate, shouldUpdateCodingCliTabIcon, } from '../../domain/sessionTabTitle'; type CodingCliIconSession = { id: string; codingCliProviderId?: CodingCliProviderId; }; export type CodingCliSessionSignalControllerDeps = { getDynamicTabTitleMode: () => DynamicTabTitleMode; getSession: (sessionId: string) => CodingCliIconSession | undefined; onUpdateSessionCodingCliProvider?: ( sessionId: string, providerId: CodingCliProviderId | null, ) => void; onUpdateSessionDynamicTitle?: (sessionId: string, title: string | null) => void; }; export type CodingCliSessionSignalController = { handleDynamicTabTitleModeChange: (mode: DynamicTabTitleMode) => void; handleCommandSubmitted: (sessionId: string, commandLine: string) => void; handleTerminalOutput: (sessionId: string, chunk: string) => void; handleTerminalTitleChange: (sessionId: string, title: string | null) => void; forgetSession: (sessionId: string) => void; }; type UseCodingCliSessionSignalsOptions = Omit< CodingCliSessionSignalControllerDeps, 'getDynamicTabTitleMode' > & { dynamicTabTitleMode: DynamicTabTitleMode; sessionIds: readonly string[]; }; export function createCodingCliSessionSignalController( deps: CodingCliSessionSignalControllerDeps, ): CodingCliSessionSignalController { const outputScanners = new Map(); const outputScanDisabled = new Set(); // TerminalLayer may memo-skip title/provider-only session updates, so the // controller keeps its own provider memory instead of trusting a stale // sessionsRef from a skipped render. const knownProviderBySession = new Map(); let observedDynamicTabTitleMode = deps.getDynamicTabTitleMode(); const resolveCurrentProviderId = (sessionId: string): CodingCliProviderId | null | undefined => { if (knownProviderBySession.has(sessionId)) { return knownProviderBySession.get(sessionId); } return deps.getSession(sessionId)?.codingCliProviderId; }; const handleDynamicTabTitleModeChange = (mode: DynamicTabTitleMode) => { if (mode === observedDynamicTabTitleMode) return; const previousMode = observedDynamicTabTitleMode; observedDynamicTabTitleMode = mode; // agent <-> all both keep live detection on; clearing would re-arm // exhausted startup scans and mis-tag ordinary mid-session output. if (previousMode === 'off' || mode === 'off') { outputScanners.clear(); outputScanDisabled.clear(); } }; const getCurrentDynamicTabTitleMode = () => { const mode = deps.getDynamicTabTitleMode(); handleDynamicTabTitleModeChange(mode); return mode; }; const applyProvider = (sessionId: string, providerId: CodingCliProviderId | null) => { const session = deps.getSession(sessionId); if (!session && !knownProviderBySession.has(sessionId)) return; const nextProviderId = resolveCodingCliProviderIconUpdate({ dynamicTabTitleMode: getCurrentDynamicTabTitleMode(), currentProviderId: resolveCurrentProviderId(sessionId), nextProviderId: providerId, }); if (nextProviderId === undefined) return; knownProviderBySession.set(sessionId, nextProviderId); deps.onUpdateSessionCodingCliProvider?.(sessionId, nextProviderId); }; const handleCommandSubmitted = (sessionId: string, commandLine: string) => { if (!shouldUpdateCodingCliTabIcon(getCurrentDynamicTabTitleMode())) return; const provider = matchCodingCliProviderFromCommand(commandLine); if (!provider) return; outputScanners.delete(sessionId); outputScanDisabled.delete(sessionId); applyProvider(sessionId, provider.id); }; const handleTerminalTitleChange = (sessionId: string, title: string | null) => { const session = deps.getSession(sessionId); if (!session && !knownProviderBySession.has(sessionId)) return; const dynamicTabTitleMode = getCurrentDynamicTabTitleMode(); const trimmedTitle = title?.trim(); const providerId = trimmedTitle ? inferCodingCliProviderFromTitleSignals(trimmedTitle) : undefined; const currentProviderId = resolveCurrentProviderId(sessionId); const shouldStoreDynamicTitle = dynamicTabTitleMode === 'all' || ( dynamicTabTitleMode === 'agent' && Boolean(currentProviderId || providerId) ); deps.onUpdateSessionDynamicTitle?.(sessionId, shouldStoreDynamicTitle ? title : null); if (!shouldUpdateCodingCliTabIcon(dynamicTabTitleMode)) return; if (!trimmedTitle) { if (currentProviderId) { outputScanners.delete(sessionId); outputScanDisabled.delete(sessionId); applyProvider(sessionId, null); } return; } if (providerId) { if (!currentProviderId || currentProviderId !== providerId) { outputScanners.delete(sessionId); outputScanDisabled.delete(sessionId); applyProvider(sessionId, providerId); } return; } if ( currentProviderId && shouldClearCodingCliProviderForTitle(trimmedTitle, currentProviderId) ) { outputScanners.delete(sessionId); outputScanDisabled.delete(sessionId); applyProvider(sessionId, null); } }; const handleTerminalOutput = (sessionId: string, chunk: string) => { const dynamicTabTitleMode = getCurrentDynamicTabTitleMode(); if (!chunk || outputScanDisabled.has(sessionId)) return; if (!shouldUpdateCodingCliTabIcon(dynamicTabTitleMode)) return; const session = deps.getSession(sessionId); if (!session && !knownProviderBySession.has(sessionId)) { outputScanners.delete(sessionId); outputScanDisabled.delete(sessionId); return; } if (resolveCurrentProviderId(sessionId)) return; let scanner = outputScanners.get(sessionId); if (!scanner) { scanner = createCodingCliOutputScanner(); outputScanners.set(sessionId, scanner); } const providerId = scanner.feed(chunk); if (providerId) { applyProvider(sessionId, providerId); return; } if (scanner.isExhausted()) { outputScanners.delete(sessionId); outputScanDisabled.add(sessionId); } }; return { handleDynamicTabTitleModeChange, handleCommandSubmitted, handleTerminalOutput, handleTerminalTitleChange, forgetSession: (sessionId: string) => { outputScanners.delete(sessionId); outputScanDisabled.delete(sessionId); knownProviderBySession.delete(sessionId); }, }; } export function useCodingCliSessionSignals( options: UseCodingCliSessionSignalsOptions, ): CodingCliSessionSignalController { const optionsRef = useRef(options); optionsRef.current = options; const controllerRef = useRef(null); if (!controllerRef.current) { controllerRef.current = createCodingCliSessionSignalController({ getDynamicTabTitleMode: () => optionsRef.current.dynamicTabTitleMode, getSession: (sessionId) => optionsRef.current.getSession(sessionId), onUpdateSessionCodingCliProvider: (sessionId, providerId) => { optionsRef.current.onUpdateSessionCodingCliProvider?.(sessionId, providerId); }, onUpdateSessionDynamicTitle: (sessionId, title) => { optionsRef.current.onUpdateSessionDynamicTitle?.(sessionId, title); }, }); } const controller = controllerRef.current; const liveSessionIdsRef = useRef(new Set(options.sessionIds)); useEffect(() => { controller.handleDynamicTabTitleModeChange(options.dynamicTabTitleMode); }, [controller, options.dynamicTabTitleMode]); useEffect(() => { const nextSessionIds = new Set(options.sessionIds); for (const sessionId of liveSessionIdsRef.current) { if (!nextSessionIds.has(sessionId)) controller.forgetSession(sessionId); } liveSessionIdsRef.current = nextSessionIds; }, [controller, options.sessionIds]); useEffect(() => () => { for (const sessionId of liveSessionIdsRef.current) { controller.forgetSession(sessionId); } }, [controller]); return controller; }