157 lines
4.2 KiB
TypeScript
157 lines
4.2 KiB
TypeScript
import type { TerminalNotification } from '../ink/useTerminalNotification.js'
|
|
import { getGlobalConfig } from '../utils/config.js'
|
|
import { env } from '../utils/env.js'
|
|
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
|
|
import { executeNotificationHooks } from '../utils/hooks.js'
|
|
import { logError } from '../utils/log.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from './analytics/index.js'
|
|
|
|
export type NotificationOptions = {
|
|
message: string
|
|
title?: string
|
|
notificationType: string
|
|
}
|
|
|
|
export async function sendNotification(
|
|
notif: NotificationOptions,
|
|
terminal: TerminalNotification,
|
|
): Promise<void> {
|
|
const config = getGlobalConfig()
|
|
const channel = config.preferredNotifChannel
|
|
|
|
await executeNotificationHooks(notif)
|
|
|
|
const methodUsed = await sendToChannel(channel, notif, terminal)
|
|
|
|
logEvent('tengu_notification_method_used', {
|
|
configured_channel:
|
|
channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
method_used:
|
|
methodUsed as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
term: env.terminal as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
}
|
|
|
|
const DEFAULT_TITLE = 'Claude Code'
|
|
|
|
async function sendToChannel(
|
|
channel: string,
|
|
opts: NotificationOptions,
|
|
terminal: TerminalNotification,
|
|
): Promise<string> {
|
|
const title = opts.title || DEFAULT_TITLE
|
|
|
|
try {
|
|
switch (channel) {
|
|
case 'auto':
|
|
return sendAuto(opts, terminal)
|
|
case 'iterm2':
|
|
terminal.notifyITerm2(opts)
|
|
return 'iterm2'
|
|
case 'iterm2_with_bell':
|
|
terminal.notifyITerm2(opts)
|
|
terminal.notifyBell()
|
|
return 'iterm2_with_bell'
|
|
case 'kitty':
|
|
terminal.notifyKitty({ ...opts, title, id: generateKittyId() })
|
|
return 'kitty'
|
|
case 'ghostty':
|
|
terminal.notifyGhostty({ ...opts, title })
|
|
return 'ghostty'
|
|
case 'terminal_bell':
|
|
terminal.notifyBell()
|
|
return 'terminal_bell'
|
|
case 'notifications_disabled':
|
|
return 'disabled'
|
|
default:
|
|
return 'none'
|
|
}
|
|
} catch {
|
|
return 'error'
|
|
}
|
|
}
|
|
|
|
async function sendAuto(
|
|
opts: NotificationOptions,
|
|
terminal: TerminalNotification,
|
|
): Promise<string> {
|
|
const title = opts.title || DEFAULT_TITLE
|
|
|
|
switch (env.terminal) {
|
|
case 'Apple_Terminal': {
|
|
const bellDisabled = await isAppleTerminalBellDisabled()
|
|
if (bellDisabled) {
|
|
terminal.notifyBell()
|
|
return 'terminal_bell'
|
|
}
|
|
return 'no_method_available'
|
|
}
|
|
case 'iTerm.app':
|
|
terminal.notifyITerm2(opts)
|
|
return 'iterm2'
|
|
case 'kitty':
|
|
terminal.notifyKitty({ ...opts, title, id: generateKittyId() })
|
|
return 'kitty'
|
|
case 'ghostty':
|
|
terminal.notifyGhostty({ ...opts, title })
|
|
return 'ghostty'
|
|
default:
|
|
return 'no_method_available'
|
|
}
|
|
}
|
|
|
|
function generateKittyId(): number {
|
|
return Math.floor(Math.random() * 10000)
|
|
}
|
|
|
|
async function isAppleTerminalBellDisabled(): Promise<boolean> {
|
|
try {
|
|
if (env.terminal !== 'Apple_Terminal') {
|
|
return false
|
|
}
|
|
|
|
const osascriptResult = await execFileNoThrow('osascript', [
|
|
'-e',
|
|
'tell application "Terminal" to name of current settings of front window',
|
|
])
|
|
const currentProfile = osascriptResult.stdout.trim()
|
|
|
|
if (!currentProfile) {
|
|
return false
|
|
}
|
|
|
|
const defaultsOutput = await execFileNoThrow('defaults', [
|
|
'export',
|
|
'com.apple.Terminal',
|
|
'-',
|
|
])
|
|
|
|
if (defaultsOutput.code !== 0) {
|
|
return false
|
|
}
|
|
|
|
// Lazy-load plist (~280KB with xmlbuilder+@xmldom) — only hit on
|
|
// Apple_Terminal with auto-channel, which is a small fraction of users.
|
|
const plist = await import('plist')
|
|
const parsed: Record<string, unknown> = plist.parse(defaultsOutput.stdout)
|
|
const windowSettings = parsed?.['Window Settings'] as
|
|
| Record<string, unknown>
|
|
| undefined
|
|
const profileSettings = windowSettings?.[currentProfile] as
|
|
| Record<string, unknown>
|
|
| undefined
|
|
|
|
if (!profileSettings) {
|
|
return false
|
|
}
|
|
|
|
return profileSettings.Bell === false
|
|
} catch (error) {
|
|
logError(error)
|
|
return false
|
|
}
|
|
}
|