feat(notifications): rename Audio Feedback to Custom Notification

Rename "Audio Feedback" → "Custom Notification" and "TTS Command" →
"Command Chain" to better reflect the flexible nature of this feature.
Users can chain commands together with pipes to mix and match
notification tools (TTS, logging, desktop notifications, etc.).

Closes #168
This commit is contained in:
Pedram Amini
2026-01-29 10:04:26 -05:00
parent fe67bcd3d6
commit 561ce42e09
4 changed files with 53 additions and 31 deletions

View File

@@ -76,20 +76,23 @@ Enable desktop notifications to be alerted when:
1. Toggle **Enable OS Notifications** on
2. Click **Test Notification** to verify it works
### Audio Feedback (Text-to-Speech)
### Custom Notification
Maestro can speak a brief summary when AI tasks complete using your system's text-to-speech.
Execute a custom command when AI tasks complete. By default, this uses text-to-speech (TTS), but you can leverage any notification stack you prefer.
**To configure:**
1. Toggle **Enable Audio Feedback** on
2. Set the **TTS Command** — the command that accepts text via stdin:
- **macOS:** `say` (built-in)
- **Linux:** `espeak` or `festival --tts`
1. Toggle **Enable Custom Notification** on
2. Set the **Command Chain** — the command(s) that accept text via stdin:
- **TTS (default):** `say` (macOS), `espeak` or `festival --tts` (Linux)
- **Windows:** Use a PowerShell script or third-party TTS tool
3. Click **Test** to hear a sample message
- **Custom:** Any command or script that accepts stdin
3. Click **Test** to verify your command works
4. Click **Stop** to interrupt a running test
**Piped commands:** You can pipe through multiple commands, e.g., `cmd1 | cmd2`.
**Command chaining:** Chain multiple commands together using pipes to mix and match tools. Examples:
- `say` — speak aloud using macOS TTS
- `tee ~/log.txt | say` — log to a file AND speak aloud
- `notify-send "Maestro" && espeak` — show desktop notification and speak (Linux)
### Toast Notifications
@@ -104,9 +107,9 @@ In-app toast notifications appear in the corner when events occur. Configure how
### When Notifications Trigger
Notifications are sent when:
- An AI task completes (OS notification + optional TTS)
- An AI task completes (OS notification + optional custom notification)
- A long-running command finishes (OS notification)
- The LLM analysis generates a feedback synopsis (TTS only, if configured)
- The LLM analysis generates a feedback synopsis (custom notification only, if configured)
## Sleep Prevention

View File

@@ -1148,14 +1148,14 @@ describe('SettingsModal', () => {
);
});
it('should display audio feedback setting', async () => {
it('should display custom notification setting', async () => {
render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications' })} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(screen.getByText('Enable Audio Feedback')).toBeInTheDocument();
expect(screen.getByText('Enable Custom Notification')).toBeInTheDocument();
});
it('should call setAudioFeedbackEnabled when toggle switch is changed', async () => {
@@ -1175,7 +1175,7 @@ describe('SettingsModal', () => {
});
// SettingCheckbox uses a button with role="switch" instead of input[type="checkbox"]
const titleElement = screen.getByText('Enable Audio Feedback');
const titleElement = screen.getByText('Enable Custom Notification');
const toggleContainer = titleElement.closest('[role="button"]');
const toggleSwitch = toggleContainer?.querySelector('button[role="switch"]');
fireEvent.click(toggleSwitch!);
@@ -1183,7 +1183,7 @@ describe('SettingsModal', () => {
expect(setAudioFeedbackEnabled).toHaveBeenCalledWith(true);
});
it('should call setAudioFeedbackCommand when TTS command is changed', async () => {
it('should call setAudioFeedbackCommand when Command Chain is changed', async () => {
const setAudioFeedbackCommand = vi.fn();
render(
<SettingsModal
@@ -1201,7 +1201,7 @@ describe('SettingsModal', () => {
expect(setAudioFeedbackCommand).toHaveBeenCalledWith('espeak');
});
it('should test TTS when test button is clicked', async () => {
it('should test Command Chain when test button is clicked', async () => {
render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications' })} />);
await act(async () => {
@@ -1520,8 +1520,8 @@ describe('SettingsModal', () => {
});
});
describe('TTS Stop button', () => {
it('should show Stop button when TTS is playing and handle click', async () => {
describe('Custom notification Stop button', () => {
it('should show Stop button when Command Chain is running and handle click', async () => {
// Mock speak to return a ttsId
vi.mocked(window.maestro.notification.speak).mockResolvedValue({ success: true, ttsId: 123 });
vi.mocked(window.maestro.notification.stopSpeak).mockResolvedValue({ success: true });
@@ -1532,7 +1532,7 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
// Click Test button to start TTS
// Click Test button to start Command Chain
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await act(async () => {
@@ -1564,7 +1564,7 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
// Click Test button to start TTS
// Click Test button to start Command Chain
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await act(async () => {
@@ -1604,7 +1604,7 @@ describe('SettingsModal', () => {
consoleSpy.mockRestore();
});
it('should auto-clear TTS state after timeout', async () => {
it('should auto-clear Command Chain state after timeout', async () => {
vi.mocked(window.maestro.notification.speak).mockResolvedValue({ success: true, ttsId: 789 });
render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications' })} />);
@@ -1613,7 +1613,7 @@ describe('SettingsModal', () => {
await vi.advanceTimersByTimeAsync(100);
});
// Click Test button to start TTS
// Click Test button to start Command Chain
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await act(async () => {

View File

@@ -1018,7 +1018,7 @@ describe('TerminalOutput', () => {
});
});
describe('TTS functionality', () => {
describe('Custom notification functionality', () => {
it('shows speak button when audioFeedbackCommand is provided', () => {
const logs: LogEntry[] = [createLogEntry({ text: 'Text to speak', source: 'stdout' })];

View File

@@ -61,21 +61,21 @@ export function NotificationsPanel({
</button>
</div>
{/* Audio Feedback */}
{/* Custom Notification */}
<div>
<SettingCheckbox
icon={Volume2}
sectionLabel="Audio Feedback"
title="Enable Audio Feedback"
description="Speak the one-sentence feedback synopsis from LLM analysis using text-to-speech"
sectionLabel="Custom Notification"
title="Enable Custom Notification"
description="Execute a custom command when AI tasks complete, such as text-to-speech feedback"
checked={audioFeedbackEnabled}
onChange={setAudioFeedbackEnabled}
theme={theme}
/>
{/* Audio Command Configuration */}
{/* Command Chain Configuration */}
<div className="mt-3">
<label className="block text-xs font-medium opacity-70 mb-1">TTS Command</label>
<label className="block text-xs font-medium opacity-70 mb-1">Command Chain</label>
<div className="flex gap-2">
<input
type="text"
@@ -137,14 +137,14 @@ export function NotificationsPanel({
)}
</div>
<p className="text-xs opacity-50 mt-2" style={{ color: theme.colors.textDim }}>
Command that accepts text via stdin. Pipes are supported (e.g.,{' '}
Command that accepts text via stdin. Chain multiple commands using pipes (e.g.,{' '}
<code
className="px-1 py-0.5 rounded"
style={{ backgroundColor: theme.colors.bgActivity }}
>
cmd1 | cmd2
</code>
). Examples:{' '}
) to mix and match tools. Default TTS examples:{' '}
<code
className="px-1 py-0.5 rounded"
style={{ backgroundColor: theme.colors.bgActivity }}
@@ -165,6 +165,14 @@ export function NotificationsPanel({
>
festival --tts
</code>
. You can also use non-TTS commands or combine them, e.g.,{' '}
<code
className="px-1 py-0.5 rounded"
style={{ backgroundColor: theme.colors.bgActivity }}
>
tee ~/log.txt | say
</code>{' '}
to log and speak simultaneously.
</p>
</div>
</div>
@@ -208,8 +216,19 @@ export function NotificationsPanel({
<ul className="text-xs opacity-70 space-y-1" style={{ color: theme.colors.textDim }}>
<li> When an AI task completes</li>
<li> When a long-running command finishes</li>
<li> When the LLM analysis generates a feedback synopsis (audio only, if configured)</li>
<li>
When the LLM analysis generates a feedback synopsis (custom notification only, if
configured)
</li>
</ul>
<div
className="text-xs opacity-60 mt-3 pt-3"
style={{ color: theme.colors.textDim, borderTop: `1px solid ${theme.colors.border}` }}
>
<strong>Tip:</strong> The default Command Chain uses TTS (text-to-speech), but you can
leverage any notification stack you prefer. Chain commands together with pipes to mix and
matchfor example, log to a file while also speaking aloud.
</div>
</div>
</div>
);