Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
237 lines
8.5 KiB
TypeScript
237 lines
8.5 KiB
TypeScript
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;
|
|
}
|