[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
236
application/state/codingCliSessionSignalController.ts
Normal file
236
application/state/codingCliSessionSignalController.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user