mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: Add Terminal Attention Notifications for User Alerts (#1052)
This commit is contained in:
@@ -97,6 +97,7 @@ import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
|
|||||||
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||||
|
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||||
|
|
||||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||||
|
|
||||||
@@ -944,6 +945,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
settings.merged.ui?.customWittyPhrases,
|
settings.merged.ui?.customWittyPhrases,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useAttentionNotifications({
|
||||||
|
isFocused,
|
||||||
|
streamingState,
|
||||||
|
elapsedTime,
|
||||||
|
});
|
||||||
|
|
||||||
// Dialog close functionality
|
// Dialog close functionality
|
||||||
const { closeAnyOpenDialog } = useDialogClose({
|
const { closeAnyOpenDialog } = useDialogClose({
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
|
|||||||
151
packages/cli/src/ui/hooks/useAttentionNotifications.test.ts
Normal file
151
packages/cli/src/ui/hooks/useAttentionNotifications.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
packages/cli/src/ui/hooks/useAttentionNotifications.ts
Normal file
63
packages/cli/src/ui/hooks/useAttentionNotifications.ts
Normal 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]);
|
||||||
|
};
|
||||||
72
packages/cli/src/utils/attentionNotification.test.ts
Normal file
72
packages/cli/src/utils/attentionNotification.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/cli/src/utils/attentionNotification.ts
Normal file
43
packages/cli/src/utils/attentionNotification.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user