Add terminal bell setting to enable/disable audio notifications

This commit is contained in:
Alexander Farber
2025-12-09 20:27:20 +01:00
parent 5b74422be6
commit 5f78909040
5 changed files with 77 additions and 4 deletions

View File

@@ -193,6 +193,16 @@ const SETTINGS_SCHEMA = {
{ value: 'zh', label: '中文 (Chinese)' }, { value: 'zh', label: '中文 (Chinese)' },
], ],
}, },
terminalBell: {
type: 'boolean',
label: 'Terminal Bell',
category: 'General',
requiresRestart: false,
default: true,
description:
'Play terminal bell sound when response completes or needs approval.',
showInDialog: true,
},
}, },
}, },
output: { output: {

View File

@@ -945,6 +945,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused, isFocused,
streamingState, streamingState,
elapsedTime, elapsedTime,
settings,
}); });
// Dialog close functionality // Dialog close functionality

View File

@@ -15,6 +15,23 @@ import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS, LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications, useAttentionNotifications,
} from './useAttentionNotifications.js'; } from './useAttentionNotifications.js';
import type { LoadedSettings } from '../../config/settings.js';
const mockSettings: LoadedSettings = {
merged: {
general: {
terminalBell: true,
},
},
} as LoadedSettings;
const mockSettingsDisabled: LoadedSettings = {
merged: {
general: {
terminalBell: false,
},
},
} as LoadedSettings;
vi.mock('../../utils/attentionNotification.js', () => ({ vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(), notifyTerminalAttention: vi.fn(),
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
isFocused: true, isFocused: true,
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
elapsedTime: 0, elapsedTime: 0,
settings: mockSettings,
...props, ...props,
}, },
}, },
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
isFocused: false, isFocused: false,
streamingState: StreamingState.WaitingForConfirmation, streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0, elapsedTime: 0,
settings: mockSettings,
}, },
}); });
expect(mockedNotify).toHaveBeenCalledWith( expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval, AttentionNotificationReason.ToolApproval,
{ enabled: true },
); );
}); });
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
isFocused: false, isFocused: false,
streamingState: StreamingState.WaitingForConfirmation, streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0, elapsedTime: 0,
settings: mockSettings,
}, },
}); });
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
isFocused: false, isFocused: false,
streamingState: StreamingState.Responding, streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
settings: mockSettings,
}, },
}); });
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
isFocused: false, isFocused: false,
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
elapsedTime: 0, elapsedTime: 0,
settings: mockSettings,
}, },
}); });
expect(mockedNotify).toHaveBeenCalledWith( expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete, AttentionNotificationReason.LongTaskComplete,
{ enabled: true },
); );
}); });
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
isFocused: true, isFocused: true,
streamingState: StreamingState.Responding, streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
settings: mockSettings,
}, },
}); });
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
isFocused: true, isFocused: true,
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
elapsedTime: 0, elapsedTime: 0,
settings: mockSettings,
}, },
}); });
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
isFocused: false, isFocused: false,
streamingState: StreamingState.Responding, streamingState: StreamingState.Responding,
elapsedTime: 5, elapsedTime: 5,
settings: mockSettings,
}, },
}); });
@@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => {
isFocused: false, isFocused: false,
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
elapsedTime: 0, elapsedTime: 0,
settings: mockSettings,
}, },
}); });
expect(mockedNotify).not.toHaveBeenCalled(); expect(mockedNotify).not.toHaveBeenCalled();
}); });
it('does not notify when terminalBell setting is disabled', () => {
const { rerender } = render({
settings: mockSettingsDisabled,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettingsDisabled,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: false },
);
});
}); });

View File

@@ -10,6 +10,7 @@ import {
notifyTerminalAttention, notifyTerminalAttention,
AttentionNotificationReason, AttentionNotificationReason,
} from '../../utils/attentionNotification.js'; } from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20; export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions {
isFocused: boolean; isFocused: boolean;
streamingState: StreamingState; streamingState: StreamingState;
elapsedTime: number; elapsedTime: number;
settings: LoadedSettings;
} }
export const useAttentionNotifications = ({ export const useAttentionNotifications = ({
isFocused, isFocused,
streamingState, streamingState,
elapsedTime, elapsedTime,
settings,
}: UseAttentionNotificationsOptions) => { }: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false); const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0); const respondingElapsedRef = useRef(0);
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
!isFocused && !isFocused &&
!awaitingNotificationSentRef.current !awaitingNotificationSentRef.current
) { ) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval); notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
enabled: terminalBellEnabled,
});
awaitingNotificationSentRef.current = true; awaitingNotificationSentRef.current = true;
} }
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) { if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false; awaitingNotificationSentRef.current = false;
} }
}, [isFocused, streamingState]); }, [isFocused, streamingState, terminalBellEnabled]);
useEffect(() => { useEffect(() => {
if (streamingState === StreamingState.Responding) { if (streamingState === StreamingState.Responding) {
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
respondingElapsedRef.current >= respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS; LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) { if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete); notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
enabled: terminalBellEnabled,
});
} }
// Reset tracking for next task // Reset tracking for next task
respondingElapsedRef.current = 0; respondingElapsedRef.current = 0;
return; return;
} }
}, [streamingState, elapsedTime, isFocused]); }, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
}; };

View File

@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
export interface TerminalNotificationOptions { export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>; stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
enabled?: boolean;
} }
const TERMINAL_BELL = '\u0007'; const TERMINAL_BELL = '\u0007';
@@ -28,6 +29,11 @@ export function notifyTerminalAttention(
_reason: AttentionNotificationReason, _reason: AttentionNotificationReason,
options: TerminalNotificationOptions = {}, options: TerminalNotificationOptions = {},
): boolean { ): boolean {
// Check if terminal bell is enabled (default true for backwards compatibility)
if (options.enabled === false) {
return false;
}
const stream = options.stream ?? process.stdout; const stream = options.stream ?? process.stdout;
if (!stream?.write || stream.isTTY === false) { if (!stream?.write || stream.isTTY === false) {
return false; return false;