feat: Add Terminal Attention Notifications for User Alerts (#1052)

This commit is contained in:
tanzhenxin
2025-11-18 13:43:43 +08:00
committed by GitHub
parent 5bc309b3dc
commit 6bb829f876
5 changed files with 336 additions and 0 deletions

View File

@@ -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,

View File

@@ -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<Parameters<typeof useAttentionNotifications>[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();
});
});

View File

@@ -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]);
};

View File

@@ -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<typeof vi.fn>; 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');
});
});
});

View File

@@ -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<NodeJS.WriteStream, 'write' | 'isTTY'>;
}
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;
}
}