Files
NetMesh/application/state/codingCliSessionSignalController.ts

237 lines
8.5 KiB
TypeScript
Raw Normal View History

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<string, CodingCliOutputScanner>();
const outputScanDisabled = new Set<string>();
// 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<string, CodingCliProviderId | null>();
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<CodingCliSessionSignalController | null>(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;
}