/ services / notifier.ts
notifier.ts
  1  import type { TerminalNotification } from '../ink/useTerminalNotification.js'
  2  import { getGlobalConfig } from '../utils/config.js'
  3  import { env } from '../utils/env.js'
  4  import { execFileNoThrow } from '../utils/execFileNoThrow.js'
  5  import { executeNotificationHooks } from '../utils/hooks.js'
  6  import { logError } from '../utils/log.js'
  7  import {
  8    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  9    logEvent,
 10  } from './analytics/index.js'
 11  
 12  export type NotificationOptions = {
 13    message: string
 14    title?: string
 15    notificationType: string
 16  }
 17  
 18  export async function sendNotification(
 19    notif: NotificationOptions,
 20    terminal: TerminalNotification,
 21  ): Promise<void> {
 22    const config = getGlobalConfig()
 23    const channel = config.preferredNotifChannel
 24  
 25    await executeNotificationHooks(notif)
 26  
 27    const methodUsed = await sendToChannel(channel, notif, terminal)
 28  
 29    logEvent('tengu_notification_method_used', {
 30      configured_channel:
 31        channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 32      method_used:
 33        methodUsed as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 34      term: env.terminal as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 35    })
 36  }
 37  
 38  const DEFAULT_TITLE = 'Claude Code'
 39  
 40  async function sendToChannel(
 41    channel: string,
 42    opts: NotificationOptions,
 43    terminal: TerminalNotification,
 44  ): Promise<string> {
 45    const title = opts.title || DEFAULT_TITLE
 46  
 47    try {
 48      switch (channel) {
 49        case 'auto':
 50          return sendAuto(opts, terminal)
 51        case 'iterm2':
 52          terminal.notifyITerm2(opts)
 53          return 'iterm2'
 54        case 'iterm2_with_bell':
 55          terminal.notifyITerm2(opts)
 56          terminal.notifyBell()
 57          return 'iterm2_with_bell'
 58        case 'kitty':
 59          terminal.notifyKitty({ ...opts, title, id: generateKittyId() })
 60          return 'kitty'
 61        case 'ghostty':
 62          terminal.notifyGhostty({ ...opts, title })
 63          return 'ghostty'
 64        case 'terminal_bell':
 65          terminal.notifyBell()
 66          return 'terminal_bell'
 67        case 'notifications_disabled':
 68          return 'disabled'
 69        default:
 70          return 'none'
 71      }
 72    } catch {
 73      return 'error'
 74    }
 75  }
 76  
 77  async function sendAuto(
 78    opts: NotificationOptions,
 79    terminal: TerminalNotification,
 80  ): Promise<string> {
 81    const title = opts.title || DEFAULT_TITLE
 82  
 83    switch (env.terminal) {
 84      case 'Apple_Terminal': {
 85        const bellDisabled = await isAppleTerminalBellDisabled()
 86        if (bellDisabled) {
 87          terminal.notifyBell()
 88          return 'terminal_bell'
 89        }
 90        return 'no_method_available'
 91      }
 92      case 'iTerm.app':
 93        terminal.notifyITerm2(opts)
 94        return 'iterm2'
 95      case 'kitty':
 96        terminal.notifyKitty({ ...opts, title, id: generateKittyId() })
 97        return 'kitty'
 98      case 'ghostty':
 99        terminal.notifyGhostty({ ...opts, title })
100        return 'ghostty'
101      default:
102        return 'no_method_available'
103    }
104  }
105  
106  function generateKittyId(): number {
107    return Math.floor(Math.random() * 10000)
108  }
109  
110  async function isAppleTerminalBellDisabled(): Promise<boolean> {
111    try {
112      if (env.terminal !== 'Apple_Terminal') {
113        return false
114      }
115  
116      const osascriptResult = await execFileNoThrow('osascript', [
117        '-e',
118        'tell application "Terminal" to name of current settings of front window',
119      ])
120      const currentProfile = osascriptResult.stdout.trim()
121  
122      if (!currentProfile) {
123        return false
124      }
125  
126      const defaultsOutput = await execFileNoThrow('defaults', [
127        'export',
128        'com.apple.Terminal',
129        '-',
130      ])
131  
132      if (defaultsOutput.code !== 0) {
133        return false
134      }
135  
136      // Lazy-load plist (~280KB with xmlbuilder+@xmldom) — only hit on
137      // Apple_Terminal with auto-channel, which is a small fraction of users.
138      const plist = await import('plist')
139      const parsed: Record<string, unknown> = plist.parse(defaultsOutput.stdout)
140      const windowSettings = parsed?.['Window Settings'] as
141        | Record<string, unknown>
142        | undefined
143      const profileSettings = windowSettings?.[currentProfile] as
144        | Record<string, unknown>
145        | undefined
146  
147      if (!profileSettings) {
148        return false
149      }
150  
151      return profileSettings.Bell === false
152    } catch (error) {
153      logError(error)
154      return false
155    }
156  }