/ utils / activityManager.ts
activityManager.ts
  1  import { getActiveTimeCounter as getActiveTimeCounterImpl } from '../bootstrap/state.js'
  2  
  3  type ActivityManagerOptions = {
  4    getNow?: () => number
  5    getActiveTimeCounter?: typeof getActiveTimeCounterImpl
  6  }
  7  
  8  /**
  9   * ActivityManager handles generic activity tracking for both user and CLI operations.
 10   * It automatically deduplicates overlapping activities and provides separate metrics
 11   * for user vs CLI active time.
 12   */
 13  export class ActivityManager {
 14    private activeOperations = new Set<string>()
 15  
 16    private lastUserActivityTime: number = 0 // Start with 0 to indicate no activity yet
 17    private lastCLIRecordedTime: number
 18  
 19    private isCLIActive: boolean = false
 20  
 21    private readonly USER_ACTIVITY_TIMEOUT_MS = 5000 // 5 seconds
 22  
 23    private readonly getNow: () => number
 24    private readonly getActiveTimeCounter: typeof getActiveTimeCounterImpl
 25  
 26    private static instance: ActivityManager | null = null
 27  
 28    constructor(options?: ActivityManagerOptions) {
 29      this.getNow = options?.getNow ?? (() => Date.now())
 30      this.getActiveTimeCounter =
 31        options?.getActiveTimeCounter ?? getActiveTimeCounterImpl
 32      this.lastCLIRecordedTime = this.getNow()
 33    }
 34  
 35    static getInstance(): ActivityManager {
 36      if (!ActivityManager.instance) {
 37        ActivityManager.instance = new ActivityManager()
 38      }
 39      return ActivityManager.instance
 40    }
 41  
 42    /**
 43     * Reset the singleton instance (for testing purposes)
 44     */
 45    static resetInstance(): void {
 46      ActivityManager.instance = null
 47    }
 48  
 49    /**
 50     * Create a new instance with custom options (for testing purposes)
 51     */
 52    static createInstance(options?: ActivityManagerOptions): ActivityManager {
 53      ActivityManager.instance = new ActivityManager(options)
 54      return ActivityManager.instance
 55    }
 56  
 57    /**
 58     * Called when user interacts with the CLI (typing, commands, etc.)
 59     */
 60    recordUserActivity(): void {
 61      // Don't record user time if CLI is active (CLI takes precedence)
 62      if (!this.isCLIActive && this.lastUserActivityTime !== 0) {
 63        const now = this.getNow()
 64        const timeSinceLastActivity = (now - this.lastUserActivityTime) / 1000
 65  
 66        if (timeSinceLastActivity > 0) {
 67          const activeTimeCounter = this.getActiveTimeCounter()
 68          if (activeTimeCounter) {
 69            const timeoutSeconds = this.USER_ACTIVITY_TIMEOUT_MS / 1000
 70  
 71            // Only record time if within the timeout window
 72            if (timeSinceLastActivity < timeoutSeconds) {
 73              activeTimeCounter.add(timeSinceLastActivity, { type: 'user' })
 74            }
 75          }
 76        }
 77      }
 78  
 79      // Update the last user activity timestamp
 80      this.lastUserActivityTime = this.getNow()
 81    }
 82  
 83    /**
 84     * Starts tracking CLI activity (tool execution, AI response, etc.)
 85     */
 86    startCLIActivity(operationId: string): void {
 87      // If operation already exists, it likely means the previous one didn't clean up
 88      // properly (e.g., component crashed/unmounted without calling end). Force cleanup
 89      // to avoid overestimating time - better to underestimate than overestimate.
 90      if (this.activeOperations.has(operationId)) {
 91        this.endCLIActivity(operationId)
 92      }
 93  
 94      const wasEmpty = this.activeOperations.size === 0
 95      this.activeOperations.add(operationId)
 96  
 97      if (wasEmpty) {
 98        this.isCLIActive = true
 99        this.lastCLIRecordedTime = this.getNow()
100      }
101    }
102  
103    /**
104     * Stops tracking CLI activity
105     */
106    endCLIActivity(operationId: string): void {
107      this.activeOperations.delete(operationId)
108  
109      if (this.activeOperations.size === 0) {
110        // Last operation ended - CLI becoming inactive
111        // Record the CLI time before switching to inactive
112        const now = this.getNow()
113        const timeSinceLastRecord = (now - this.lastCLIRecordedTime) / 1000
114  
115        if (timeSinceLastRecord > 0) {
116          const activeTimeCounter = this.getActiveTimeCounter()
117          if (activeTimeCounter) {
118            activeTimeCounter.add(timeSinceLastRecord, { type: 'cli' })
119          }
120        }
121  
122        this.lastCLIRecordedTime = now
123        this.isCLIActive = false
124      }
125    }
126  
127    /**
128     * Convenience method to track an async operation automatically (mainly for testing/debugging)
129     */
130    async trackOperation<T>(
131      operationId: string,
132      fn: () => Promise<T>,
133    ): Promise<T> {
134      this.startCLIActivity(operationId)
135      try {
136        return await fn()
137      } finally {
138        this.endCLIActivity(operationId)
139      }
140    }
141  
142    /**
143     * Gets current activity states (mainly for testing/debugging)
144     */
145    getActivityStates(): {
146      isUserActive: boolean
147      isCLIActive: boolean
148      activeOperationCount: number
149    } {
150      const now = this.getNow()
151      const timeSinceUserActivity = (now - this.lastUserActivityTime) / 1000
152      const isUserActive =
153        timeSinceUserActivity < this.USER_ACTIVITY_TIMEOUT_MS / 1000
154  
155      return {
156        isUserActive,
157        isCLIActive: this.isCLIActive,
158        activeOperationCount: this.activeOperations.size,
159      }
160    }
161  }
162  
163  // Export singleton instance
164  export const activityManager = ActivityManager.getInstance()