diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fbfc732b..c3443b43 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -97,6 +97,7 @@ import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; +import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -944,6 +945,12 @@ export const AppContainer = (props: AppContainerProps) => { settings.merged.ui?.customWittyPhrases, ); + useAttentionNotifications({ + isFocused, + streamingState, + elapsedTime, + }); + // Dialog close functionality const { closeAnyOpenDialog } = useDialogClose({ isThemeDialogOpen, diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts new file mode 100644 index 00000000..1475aa52 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StreamingState } from '../types.js'; +import { + AttentionNotificationReason, + notifyTerminalAttention, +} from '../../utils/attentionNotification.js'; +import { + LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS, + useAttentionNotifications, +} from './useAttentionNotifications.js'; + +vi.mock('../../utils/attentionNotification.js', () => ({ + notifyTerminalAttention: vi.fn(), + AttentionNotificationReason: { + ToolApproval: 'tool_approval', + LongTaskComplete: 'long_task_complete', + }, +})); + +const mockedNotify = vi.mocked(notifyTerminalAttention); + +describe('useAttentionNotifications', () => { + beforeEach(() => { + mockedNotify.mockReset(); + }); + + const render = ( + props?: Partial[0]>, + ) => + renderHook(({ hookProps }) => useAttentionNotifications(hookProps), { + initialProps: { + hookProps: { + isFocused: true, + streamingState: StreamingState.Idle, + elapsedTime: 0, + ...props, + }, + }, + }); + + it('notifies when tool approval is required while unfocused', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.ToolApproval, + ); + }); + + it('notifies when focus is lost after entering approval wait state', () => { + const { rerender } = render({ + isFocused: true, + streamingState: StreamingState.WaitingForConfirmation, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).toHaveBeenCalledTimes(1); + }); + + it('sends a notification when a long task finishes while unfocused', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Responding, + elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5, + }, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Idle, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.LongTaskComplete, + ); + }); + + it('does not notify about long tasks when the CLI is focused', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: true, + streamingState: StreamingState.Responding, + elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2, + }, + }); + + rerender({ + hookProps: { + isFocused: true, + streamingState: StreamingState.Idle, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).not.toHaveBeenCalledWith( + AttentionNotificationReason.LongTaskComplete, + expect.anything(), + ); + }); + + it('does not treat short responses as long tasks', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Responding, + elapsedTime: 5, + }, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Idle, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts new file mode 100644 index 00000000..e632c827 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef } from 'react'; +import { StreamingState } from '../types.js'; +import { + notifyTerminalAttention, + AttentionNotificationReason, +} from '../../utils/attentionNotification.js'; + +export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20; + +interface UseAttentionNotificationsOptions { + isFocused: boolean; + streamingState: StreamingState; + elapsedTime: number; +} + +export const useAttentionNotifications = ({ + isFocused, + streamingState, + elapsedTime, +}: UseAttentionNotificationsOptions) => { + const awaitingNotificationSentRef = useRef(false); + const respondingElapsedRef = useRef(0); + + useEffect(() => { + if ( + streamingState === StreamingState.WaitingForConfirmation && + !isFocused && + !awaitingNotificationSentRef.current + ) { + notifyTerminalAttention(AttentionNotificationReason.ToolApproval); + awaitingNotificationSentRef.current = true; + } + + if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) { + awaitingNotificationSentRef.current = false; + } + }, [isFocused, streamingState]); + + useEffect(() => { + if (streamingState === StreamingState.Responding) { + respondingElapsedRef.current = elapsedTime; + return; + } + + if (streamingState === StreamingState.Idle) { + const wasLongTask = + respondingElapsedRef.current >= + LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS; + if (wasLongTask && !isFocused) { + notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete); + } + // Reset tracking for next task + respondingElapsedRef.current = 0; + return; + } + }, [streamingState, elapsedTime, isFocused]); +}; diff --git a/packages/cli/src/utils/attentionNotification.test.ts b/packages/cli/src/utils/attentionNotification.test.ts new file mode 100644 index 00000000..9ebb785c --- /dev/null +++ b/packages/cli/src/utils/attentionNotification.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + notifyTerminalAttention, + AttentionNotificationReason, +} from './attentionNotification.js'; + +describe('notifyTerminalAttention', () => { + let stream: { write: ReturnType; isTTY: boolean }; + + beforeEach(() => { + stream = { write: vi.fn().mockReturnValue(true), isTTY: true }; + }); + + it('emits terminal bell character', () => { + const result = notifyTerminalAttention( + AttentionNotificationReason.ToolApproval, + { + stream, + }, + ); + + expect(result).toBe(true); + expect(stream.write).toHaveBeenCalledWith('\u0007'); + }); + + it('returns false when not running inside a tty', () => { + stream.isTTY = false; + + const result = notifyTerminalAttention( + AttentionNotificationReason.ToolApproval, + { stream }, + ); + + expect(result).toBe(false); + expect(stream.write).not.toHaveBeenCalled(); + }); + + it('returns false when stream write fails', () => { + stream.write = vi.fn().mockImplementation(() => { + throw new Error('Write failed'); + }); + + const result = notifyTerminalAttention( + AttentionNotificationReason.ToolApproval, + { stream }, + ); + + expect(result).toBe(false); + }); + + it('works with different notification reasons', () => { + const reasons = [ + AttentionNotificationReason.ToolApproval, + AttentionNotificationReason.LongTaskComplete, + ]; + + reasons.forEach((reason) => { + stream.write.mockClear(); + + const result = notifyTerminalAttention(reason, { stream }); + + expect(result).toBe(true); + expect(stream.write).toHaveBeenCalledWith('\u0007'); + }); + }); +}); diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts new file mode 100644 index 00000000..26dc2a25 --- /dev/null +++ b/packages/cli/src/utils/attentionNotification.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; + +export enum AttentionNotificationReason { + ToolApproval = 'tool_approval', + LongTaskComplete = 'long_task_complete', +} + +export interface TerminalNotificationOptions { + stream?: Pick; +} + +const TERMINAL_BELL = '\u0007'; + +/** + * Grabs the user's attention by emitting the terminal bell character. + * This causes the terminal to flash or play a sound, alerting the user + * to check the CLI for important events. + * + * @returns true when the bell was successfully written to the terminal. + */ +export function notifyTerminalAttention( + _reason: AttentionNotificationReason, + options: TerminalNotificationOptions = {}, +): boolean { + const stream = options.stream ?? process.stdout; + if (!stream?.write || stream.isTTY === false) { + return false; + } + + try { + stream.write(TERMINAL_BELL); + return true; + } catch (error) { + console.warn('Failed to send terminal bell:', error); + return false; + } +}