Skip to content

Commit cc8e3ef

Browse files
committed
system notification like kilocode
1 parent 0c481a3 commit cc8e3ef

29 files changed

+462
-2
lines changed

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const globalSettingsSchema = z.object({
9797
ttsSpeed: z.number().optional(),
9898
soundEnabled: z.boolean().optional(),
9999
soundVolume: z.number().optional(),
100+
systemNotificationsEnabled: z.boolean().optional(),
100101

101102
maxOpenTabsContext: z.number().optional(),
102103
maxWorkspaceFiles: z.number().optional(),
@@ -261,6 +262,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
261262
ttsSpeed: 1,
262263
soundEnabled: false,
263264
soundVolume: 0.5,
265+
systemNotificationsEnabled: false,
264266

265267
terminalOutputLineLimit: 500,
266268
terminalOutputCharacterLimit: DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,7 @@ export class ClineProvider
16981698
soundEnabled,
16991699
ttsEnabled,
17001700
ttsSpeed,
1701+
systemNotificationsEnabled,
17011702
diffEnabled,
17021703
enableCheckpoints,
17031704
taskHistory,
@@ -1804,6 +1805,7 @@ export class ClineProvider
18041805
soundEnabled: soundEnabled ?? false,
18051806
ttsEnabled: ttsEnabled ?? false,
18061807
ttsSpeed: ttsSpeed ?? 1.0,
1808+
systemNotificationsEnabled: systemNotificationsEnabled ?? false,
18071809
diffEnabled: diffEnabled ?? true,
18081810
enableCheckpoints: enableCheckpoints ?? true,
18091811
shouldShowAnnouncement:
@@ -1999,6 +2001,7 @@ export class ClineProvider
19992001
soundEnabled: stateValues.soundEnabled ?? false,
20002002
ttsEnabled: stateValues.ttsEnabled ?? false,
20012003
ttsSpeed: stateValues.ttsSpeed ?? 1.0,
2004+
systemNotificationsEnabled: stateValues.systemNotificationsEnabled ?? false,
20022005
diffEnabled: stateValues.diffEnabled ?? true,
20032006
enableCheckpoints: stateValues.enableCheckpoints ?? true,
20042007
soundVolume: stateValues.soundVolume,

src/core/webview/webviewMessageHandler.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { checkExistKey } from "../../shared/checkExistApiConfig"
2929
import { experimentDefault } from "../../shared/experiments"
3030
import { Terminal } from "../../integrations/terminal/Terminal"
3131
import { openFile } from "../../integrations/misc/open-file"
32+
import { showSystemNotification } from "../../integrations/notifications"
3233
import { CodeIndexManager } from "../../services/code-index/manager"
3334
import { openImage, saveImage } from "../../integrations/misc/image-handler"
3435
import { selectImages } from "../../integrations/misc/process-images"
@@ -975,6 +976,20 @@ export const webviewMessageHandler = async (
975976
await updateGlobalState("soundVolume", soundVolume)
976977
await provider.postStateToWebview()
977978
break
979+
case "showSystemNotification":
980+
const isSystemNotificationsEnabled = getGlobalState("systemNotificationsEnabled") ?? false
981+
if (!isSystemNotificationsEnabled) {
982+
break
983+
}
984+
if (message.notificationOptions) {
985+
showSystemNotification(message.notificationOptions)
986+
}
987+
break
988+
case "systemNotificationsEnabled":
989+
const systemNotificationsEnabled = message.bool ?? true
990+
await updateGlobalState("systemNotificationsEnabled", systemNotificationsEnabled)
991+
await provider.postStateToWebview()
992+
break
978993
case "ttsEnabled":
979994
const ttsEnabled = message.bool ?? true
980995
await updateGlobalState("ttsEnabled", ttsEnabled)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { execa } from "execa"
2+
import { platform } from "os"
3+
4+
interface NotificationOptions {
5+
title?: string
6+
subtitle?: string
7+
message: string
8+
}
9+
10+
async function showMacOSNotification(options: NotificationOptions): Promise<void> {
11+
const { title, subtitle = "", message } = options
12+
13+
const script = `display notification "${message}" with title "${title}" subtitle "${subtitle}" sound name "Tink"`
14+
15+
try {
16+
await execa("osascript", ["-e", script])
17+
} catch (error) {
18+
throw new Error(`Failed to show macOS notification: ${error}`)
19+
}
20+
}
21+
22+
async function showWindowsNotification(options: NotificationOptions): Promise<void> {
23+
const { subtitle, message } = options
24+
25+
const script = `
26+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
27+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
28+
29+
$template = @"
30+
<toast>
31+
<visual>
32+
<binding template="ToastText02">
33+
<text id="1">${subtitle}</text>
34+
<text id="2">${message}</text>
35+
</binding>
36+
</visual>
37+
</toast>
38+
"@
39+
40+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
41+
$xml.LoadXml($template)
42+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
43+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Roo Code").Show($toast)
44+
`
45+
46+
try {
47+
await execa("powershell", ["-Command", script])
48+
} catch (error) {
49+
throw new Error(`Failed to show Windows notification: ${error}`)
50+
}
51+
}
52+
53+
async function showLinuxNotification(options: NotificationOptions): Promise<void> {
54+
const { title = "", subtitle = "", message } = options
55+
56+
// Combine subtitle and message if subtitle exists
57+
const fullMessage = subtitle ? `${subtitle}\n${message}` : message
58+
59+
try {
60+
await execa("notify-send", [title, fullMessage])
61+
} catch (error) {
62+
throw new Error(`Failed to show Linux notification: ${error}`)
63+
}
64+
}
65+
66+
export async function showSystemNotification(options: NotificationOptions): Promise<void> {
67+
try {
68+
const { title = "Roo Code", message } = options
69+
70+
if (!message) {
71+
throw new Error("Message is required")
72+
}
73+
74+
const escapedOptions = {
75+
...options,
76+
title: title.replace(/"/g, '\\"'),
77+
message: message.replace(/"/g, '\\"'),
78+
subtitle: options.subtitle?.replace(/"/g, '\\"') || "",
79+
}
80+
81+
switch (platform()) {
82+
case "darwin":
83+
await showMacOSNotification(escapedOptions)
84+
break
85+
case "win32":
86+
await showWindowsNotification(escapedOptions)
87+
break
88+
case "linux":
89+
await showLinuxNotification(escapedOptions)
90+
break
91+
default:
92+
throw new Error("Unsupported platform")
93+
}
94+
} catch (error) {
95+
console.error("Could not show system notification", error)
96+
}
97+
}

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface ExtensionMessage {
9696
| "remoteBrowserEnabled"
9797
| "ttsStart"
9898
| "ttsStop"
99+
| "showSystemNotification"
99100
| "maxReadFileLine"
100101
| "fileSearchResults"
101102
| "toggleApiConfigPin"
@@ -233,6 +234,7 @@ export type ExtensionState = Pick<
233234
| "ttsSpeed"
234235
| "soundEnabled"
235236
| "soundVolume"
237+
| "systemNotificationsEnabled"
236238
// | "maxOpenTabsContext" // Optional in GlobalSettings, required here.
237239
// | "maxWorkspaceFiles" // Optional in GlobalSettings, required here.
238240
// | "showRooIgnoredFiles" // Optional in GlobalSettings, required here.

src/shared/WebviewMessage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export interface WebviewMessage {
9494
| "ttsEnabled"
9595
| "ttsSpeed"
9696
| "soundVolume"
97+
| "showSystemNotification"
98+
| "systemNotificationsEnabled"
9799
| "diffEnabled"
98100
| "enableCheckpoints"
99101
| "browserViewportSize"
@@ -274,6 +276,11 @@ export interface WebviewMessage {
274276
codebaseIndexGeminiApiKey?: string
275277
codebaseIndexMistralApiKey?: string
276278
}
279+
notificationOptions?: {
280+
title?: string
281+
subtitle?: string
282+
message: string
283+
}
277284
}
278285

279286
export const checkoutDiffPayloadSchema = z.object({

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { CheckpointWarning } from "./CheckpointWarning"
5757
import QueuedMessages from "./QueuedMessages"
5858
import { getLatestTodo } from "@roo/todo"
5959
import { QueuedMessage } from "@roo-code/types"
60+
import { showSystemNotification } from "@/utils/showSystemNotification"
6061

6162
export interface ChatViewProps {
6263
isHidden: boolean
@@ -276,6 +277,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
276277
switch (lastMessage.ask) {
277278
case "api_req_failed":
278279
playSound("progress_loop")
280+
showSystemNotification(t("settings:notifications.system.apiReqFailed"))
279281
setSendingDisabled(true)
280282
setClineAsk("api_req_failed")
281283
setEnableButtons(true)
@@ -284,6 +286,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
284286
break
285287
case "mistake_limit_reached":
286288
playSound("progress_loop")
289+
showSystemNotification(t("settings:notifications.system.mistakeLimitReached"))
287290
setSendingDisabled(false)
288291
setClineAsk("mistake_limit_reached")
289292
setEnableButtons(true)
@@ -293,6 +296,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
293296
case "followup":
294297
if (!isPartial) {
295298
playSound("notification")
299+
showSystemNotification(t("settings:notifications.system.followup"))
296300
}
297301
setSendingDisabled(isPartial)
298302
setClineAsk("followup")
@@ -307,6 +311,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
307311
case "tool":
308312
if (!isAutoApproved(lastMessage) && !isPartial) {
309313
playSound("notification")
314+
showSystemNotification(t("settings:notifications.system.toolRequest"))
310315
}
311316
setSendingDisabled(isPartial)
312317
setClineAsk("tool")
@@ -342,6 +347,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
342347
case "browser_action_launch":
343348
if (!isAutoApproved(lastMessage) && !isPartial) {
344349
playSound("notification")
350+
showSystemNotification(t("settings:notifications.system.browserAction"))
345351
}
346352
setSendingDisabled(isPartial)
347353
setClineAsk("browser_action_launch")
@@ -352,6 +358,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
352358
case "command":
353359
if (!isAutoApproved(lastMessage) && !isPartial) {
354360
playSound("notification")
361+
showSystemNotification(t("settings:notifications.system.command"))
355362
}
356363
setSendingDisabled(isPartial)
357364
setClineAsk("command")
@@ -369,6 +376,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
369376
case "use_mcp_server":
370377
if (!isAutoApproved(lastMessage) && !isPartial) {
371378
playSound("notification")
379+
showSystemNotification(t("settings:notifications.system.useMcpServer"))
372380
}
373381
setSendingDisabled(isPartial)
374382
setClineAsk("use_mcp_server")
@@ -380,6 +388,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
380388
// extension waiting for feedback. but we can just present a new task button
381389
if (!isPartial) {
382390
playSound("celebration")
391+
showSystemNotification(t("settings:notifications.system.completionResult"))
383392
}
384393
setSendingDisabled(isPartial)
385394
setClineAsk("completion_result")

webview-ui/src/components/settings/NotificationSettings.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,47 @@
11
import { HTMLAttributes } from "react"
22
import { useAppTranslation } from "@/i18n/TranslationContext"
3-
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
3+
import { VSCodeCheckbox, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
44
import { Bell } from "lucide-react"
55

66
import { SetCachedStateField } from "./types"
77
import { SectionHeader } from "./SectionHeader"
88
import { Section } from "./Section"
99
import { Slider } from "../ui"
10+
import { vscode } from "../../utils/vscode"
1011

1112
type NotificationSettingsProps = HTMLAttributes<HTMLDivElement> & {
1213
ttsEnabled?: boolean
1314
ttsSpeed?: number
1415
soundEnabled?: boolean
1516
soundVolume?: number
16-
setCachedStateField: SetCachedStateField<"ttsEnabled" | "ttsSpeed" | "soundEnabled" | "soundVolume">
17+
systemNotificationsEnabled?: boolean
18+
setCachedStateField: SetCachedStateField<
19+
"ttsEnabled" | "ttsSpeed" | "soundEnabled" | "soundVolume" | "systemNotificationsEnabled"
20+
>
21+
isChangeDetected: boolean
1722
}
1823

1924
export const NotificationSettings = ({
2025
ttsEnabled,
2126
ttsSpeed,
2227
soundEnabled,
2328
soundVolume,
29+
systemNotificationsEnabled,
30+
isChangeDetected,
2431
setCachedStateField,
2532
...props
2633
}: NotificationSettingsProps) => {
2734
const { t } = useAppTranslation()
35+
36+
const onTestNotificationClick = () => {
37+
vscode.postMessage({
38+
type: "showSystemNotification",
39+
notificationOptions: {
40+
title: t("settings.notifications.system.testTitle"),
41+
message: t("settings.notifications.system.testMessage"),
42+
},
43+
})
44+
}
2845
return (
2946
<div {...props}>
3047
<SectionHeader>
@@ -35,6 +52,26 @@ export const NotificationSettings = ({
3552
</SectionHeader>
3653

3754
<Section>
55+
<div>
56+
<VSCodeCheckbox
57+
checked={systemNotificationsEnabled}
58+
onChange={(e: any) => setCachedStateField("systemNotificationsEnabled", e.target.checked)}
59+
data-testid="system-notifications-enabled-checkbox">
60+
<span className="font-medium">{t("settings:notifications.system.label")}</span>
61+
</VSCodeCheckbox>
62+
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-2">
63+
{t("settings:notifications.system.description")}
64+
</div>
65+
{systemNotificationsEnabled && !isChangeDetected && (
66+
<VSCodeButton
67+
appearance="secondary"
68+
onClick={onTestNotificationClick}
69+
data-testid="test-system-notification-button">
70+
{t("settings:notifications.system.testButton")}
71+
</VSCodeButton>
72+
)}
73+
</div>
74+
3875
<div>
3976
<VSCodeCheckbox
4077
checked={ttsEnabled}

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
154154
ttsEnabled,
155155
ttsSpeed,
156156
soundVolume,
157+
systemNotificationsEnabled,
157158
telemetrySetting,
158159
terminalOutputLineLimit,
159160
terminalOutputCharacterLimit,
@@ -300,6 +301,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
300301
vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled })
301302
vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed })
302303
vscode.postMessage({ type: "soundVolume", value: soundVolume })
304+
vscode.postMessage({ type: "systemNotificationsEnabled", bool: systemNotificationsEnabled })
303305
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
304306
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
305307
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
@@ -663,7 +665,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
663665
ttsSpeed={ttsSpeed}
664666
soundEnabled={soundEnabled}
665667
soundVolume={soundVolume}
668+
systemNotificationsEnabled={systemNotificationsEnabled}
666669
setCachedStateField={setCachedStateField}
670+
isChangeDetected={isChangeDetected}
667671
/>
668672
)}
669673

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export interface ExtensionStateContextType extends ExtensionState {
8484
setTerminalZdotdir: (value: boolean) => void
8585
setTtsEnabled: (value: boolean) => void
8686
setTtsSpeed: (value: number) => void
87+
setSystemNotificationsEnabled: (value: boolean) => void
8788
setDiffEnabled: (value: boolean) => void
8889
setEnableCheckpoints: (value: boolean) => void
8990
setBrowserViewportSize: (value: string) => void
@@ -441,6 +442,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
441442
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
442443
setTtsEnabled: (value) => setState((prevState) => ({ ...prevState, ttsEnabled: value })),
443444
setTtsSpeed: (value) => setState((prevState) => ({ ...prevState, ttsSpeed: value })),
445+
setSystemNotificationsEnabled: (value) =>
446+
setState((prevState) => ({ ...prevState, systemNotificationsEnabled: value })),
444447
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
445448
setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })),
446449
setBrowserViewportSize: (value: string) =>

0 commit comments

Comments
 (0)