import type { TypingMode } from "../../config/types.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { TypingController } from "./typing.js"; export type TypingModeContext = { configured?: TypingMode; isGroupChat: boolean; wasMentioned: boolean; isHeartbeat: boolean; }; export const DEFAULT_GROUP_TYPING_MODE: TypingMode = "message"; export function resolveTypingMode({ configured, isGroupChat, wasMentioned, isHeartbeat, }: TypingModeContext): TypingMode { if (isHeartbeat) return "never"; if (configured) return configured; if (!isGroupChat || wasMentioned) return "instant"; return DEFAULT_GROUP_TYPING_MODE; } export type TypingSignaler = { mode: TypingMode; shouldStartImmediately: boolean; shouldStartOnMessageStart: boolean; shouldStartOnText: boolean; shouldStartOnReasoning: boolean; signalRunStart: () => Promise; signalMessageStart: () => Promise; signalTextDelta: (text?: string) => Promise; signalReasoningDelta: () => Promise; signalToolStart: () => Promise; }; export function createTypingSignaler(params: { typing: TypingController; mode: TypingMode; isHeartbeat: boolean; }): TypingSignaler { const { typing, mode, isHeartbeat } = params; const shouldStartImmediately = mode === "instant"; const shouldStartOnMessageStart = mode === "message"; const shouldStartOnText = mode === "message" || mode === "instant"; const shouldStartOnReasoning = mode === "thinking"; const disabled = isHeartbeat || mode === "never"; let hasRenderableText = false; const isRenderableText = (text?: string): boolean => { const trimmed = text?.trim(); if (!trimmed) return false; return !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN); }; const signalRunStart = async () => { if (disabled || !shouldStartImmediately) return; await typing.startTypingLoop(); }; const signalMessageStart = async () => { if (disabled || !shouldStartOnMessageStart) return; if (!hasRenderableText) return; await typing.startTypingLoop(); }; const signalTextDelta = async (text?: string) => { if (disabled) return; const renderable = isRenderableText(text); if (renderable) { hasRenderableText = true; } else if (text?.trim()) { return; } if (shouldStartOnText) { await typing.startTypingOnText(text); return; } if (shouldStartOnReasoning) { if (!typing.isActive()) { await typing.startTypingLoop(); } typing.refreshTypingTtl(); } }; const signalReasoningDelta = async () => { if (disabled || !shouldStartOnReasoning) return; if (!hasRenderableText) return; await typing.startTypingLoop(); typing.refreshTypingTtl(); }; const signalToolStart = async () => { if (disabled) return; // Start typing as soon as tools begin executing, even before the first text delta. if (!typing.isActive()) { await typing.startTypingLoop(); typing.refreshTypingTtl(); return; } // Keep typing indicator alive during tool execution. typing.refreshTypingTtl(); }; return { mode, shouldStartImmediately, shouldStartOnMessageStart, shouldStartOnText, shouldStartOnReasoning, signalRunStart, signalMessageStart, signalTextDelta, signalReasoningDelta, signalToolStart, }; }