Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences } from 'electron'
import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences, Notification } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, createReadStream } from 'fs'
import { createInterface } from 'readline'
Expand Down Expand Up @@ -90,6 +90,80 @@ controlPlane.on('error', (tabId: string, error: EnrichedError) => {
broadcast('clui:enriched-error', tabId, error)
})

// ─── Native Notifications ───
// Track the last prompt per tab so we can use it as notification body text.
const lastPromptByTab = new Map<string, string>()
// Notification mode: 'always' | 'tab-hidden' | 'window-hidden'
// The renderer sends updates via IPC; 'window-hidden' is the default.
let notificationMode: 'always' | 'tab-hidden' | 'window-hidden' = 'window-hidden'
// The renderer tells us which tab is actively visible (active + expanded).
let activeVisibleTabId: string | null = null

ipcMain.on(IPC.SET_NOTIFICATION_MODE, (_event, mode: string) => {
if (mode === 'always' || mode === 'tab-hidden' || mode === 'window-hidden') {
log(`Notification mode: ${mode}`)
notificationMode = mode
}
})

ipcMain.on(IPC.SET_ACTIVE_TAB, (_event, tabId: string | null) => {
activeVisibleTabId = tabId
})

function shouldNotify(tabId: string): boolean {
switch (notificationMode) {
case 'always':
return true
case 'tab-hidden':
// Notify if the completed tab is not the one actively being viewed,
// or if the window is hidden entirely.
return !mainWindow?.isVisible() || activeVisibleTabId !== tabId
case 'window-hidden':
return !mainWindow?.isVisible()
default:
return false
}
}

function showNotification(title: string, body: string, tabId: string): void {
if (!Notification.isSupported()) {
log('Notifications not supported on this platform')
return
}

const notification = new Notification({
title,
body,
silent: true, // the app already plays its own sound
})
notification.on('click', () => {
showWindow('notification click')
broadcast(IPC.FOCUS_TAB, tabId)
})
notification.show()
}

controlPlane.on('event', (tabId: string, event: NormalizedEvent) => {
if (!shouldNotify(tabId)) return

if (event.type === 'task_complete') {
const prompt = lastPromptByTab.get(tabId) || ''
const body = event.result
? event.result.substring(0, 100) + (event.result.length > 100 ? '...' : '')
: prompt
? prompt.substring(0, 100) + (prompt.length > 100 ? '...' : '')
: 'Your task has finished.'

showNotification('Task Complete', body, tabId)
} else if (event.type === 'error') {
showNotification(
'Task Failed',
event.message?.substring(0, 100) || 'An error occurred.',
tabId,
)
}
})

// ─── Window Creation ───

function createWindow(): void {
Expand Down Expand Up @@ -295,6 +369,8 @@ ipcMain.handle(IPC.PROMPT, async (_event, { tabId, requestId, options }: { tabId
throw new Error('No requestId provided — prompt rejected')
}

lastPromptByTab.set(tabId, options.prompt)

try {
await controlPlane.submitPrompt(tabId, requestId, options)
} catch (err: unknown) {
Expand Down Expand Up @@ -330,6 +406,7 @@ ipcMain.handle(IPC.TAB_HEALTH, () => {
ipcMain.handle(IPC.CLOSE_TAB, (_event, tabId: string) => {
log(`IPC CLOSE_TAB: ${tabId}`)
controlPlane.closeTab(tabId)
lastPromptByTab.delete(tabId)
})

ipcMain.on(IPC.SET_PERMISSION_MODE, (_event, mode: string) => {
Expand Down
11 changes: 11 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface CluiAPI {
installPlugin(repo: string, pluginName: string, marketplace: string, sourcePath?: string, isSkillMd?: boolean): Promise<{ ok: boolean; error?: string }>
uninstallPlugin(pluginName: string): Promise<{ ok: boolean; error?: string }>
setPermissionMode(mode: string): void
setNotificationMode(mode: string): void
setActiveTab(tabId: string | null): void
getTheme(): Promise<{ isDark: boolean }>
onThemeChange(callback: (isDark: boolean) => void): () => void

Expand All @@ -49,6 +51,7 @@ export interface CluiAPI {
onError(callback: (tabId: string, error: EnrichedError) => void): () => void
onSkillStatus(callback: (status: { name: string; state: string; error?: string; reason?: string }) => void): () => void
onWindowShown(callback: () => void): () => void
onFocusTab(callback: (tabId: string) => void): () => void
}

const api: CluiAPI = {
Expand Down Expand Up @@ -83,6 +86,8 @@ const api: CluiAPI = {
uninstallPlugin: (pluginName) =>
ipcRenderer.invoke(IPC.MARKETPLACE_UNINSTALL, { pluginName }),
setPermissionMode: (mode) => ipcRenderer.send(IPC.SET_PERMISSION_MODE, mode),
setNotificationMode: (mode) => ipcRenderer.send(IPC.SET_NOTIFICATION_MODE, mode),
setActiveTab: (tabId) => ipcRenderer.send(IPC.SET_ACTIVE_TAB, tabId),
getTheme: () => ipcRenderer.invoke(IPC.GET_THEME),
onThemeChange: (callback) => {
const handler = (_e: Electron.IpcRendererEvent, isDark: boolean) => callback(isDark)
Expand Down Expand Up @@ -138,6 +143,12 @@ const api: CluiAPI = {
ipcRenderer.on(IPC.WINDOW_SHOWN, handler)
return () => ipcRenderer.removeListener(IPC.WINDOW_SHOWN, handler)
},

onFocusTab: (callback) => {
const handler = (_e: Electron.IpcRendererEvent, tabId: string) => callback(tabId)
ipcRenderer.on(IPC.FOCUS_TAB, handler)
return () => ipcRenderer.removeListener(IPC.FOCUS_TAB, handler)
},
}

contextBridge.exposeInMainWorld('clui', api)
34 changes: 32 additions & 2 deletions src/renderer/components/SettingsPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import { DotsThree, Bell, ArrowsOutSimple, Moon } from '@phosphor-icons/react'
import { useThemeStore } from '../theme'
import { DotsThree, Bell, ArrowsOutSimple, Moon, BellRinging } from '@phosphor-icons/react'
import { useThemeStore, type NotificationMode } from '../theme'
import { useSessionStore } from '../stores/sessionStore'
import { usePopoverLayer } from './PopoverLayer'
import { useColors } from '../theme'
Expand Down Expand Up @@ -50,6 +50,8 @@ export function SettingsPopover() {
const setThemeMode = useThemeStore((s) => s.setThemeMode)
const expandedUI = useThemeStore((s) => s.expandedUI)
const setExpandedUI = useThemeStore((s) => s.setExpandedUI)
const notificationMode = useThemeStore((s) => s.notificationMode)
const setNotificationMode = useThemeStore((s) => s.setNotificationMode)
const isExpanded = useSessionStore((s) => s.isExpanded)
const popoverLayer = usePopoverLayer()
const colors = useColors()
Expand Down Expand Up @@ -204,6 +206,34 @@ export function SettingsPopover() {

<div style={{ height: 1, background: colors.popoverBorder }} />

{/* Notifications */}
<div>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<BellRinging size={14} style={{ color: colors.textTertiary }} />
<div className="text-[12px] font-medium" style={{ color: colors.textPrimary }}>
Notifications
</div>
</div>
<select
value={notificationMode}
onChange={(e) => setNotificationMode(e.target.value as NotificationMode)}
className="text-[11px] rounded-md px-1.5 py-0.5 outline-none cursor-pointer"
style={{
background: colors.surfacePrimary,
color: colors.textSecondary,
border: `1px solid ${colors.containerBorder}`,
}}
>
<option value="always">Always</option>
<option value="tab-hidden">Tab hidden</option>
<option value="window-hidden">Window hidden</option>
</select>
</div>
</div>

<div style={{ height: 1, background: colors.popoverBorder }} />

{/* Theme */}
<div>
<div className="flex items-center justify-between gap-3">
Expand Down
32 changes: 30 additions & 2 deletions src/renderer/hooks/useClaudeEvents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { useSessionStore } from '../stores/sessionStore'
import { useThemeStore } from '../theme'
import type { NormalizedEvent } from '../../shared/types'

/**
Expand Down Expand Up @@ -73,16 +74,43 @@ export function useClaudeEvents() {
}
})

const unsubFocusTab = window.clui.onFocusTab((tabId) => {
// Always switch to the tab and expand — don't toggle like selectTab does
const store = useSessionStore.getState()
if (store.tabs.some((t) => t.id === tabId)) {
useSessionStore.setState((prev) => ({
activeTabId: tabId,
isExpanded: true,
marketplaceOpen: false,
tabs: prev.tabs.map((t) =>
t.id === tabId ? { ...t, hasUnread: false } : t
),
}))
}
})

return () => {
unsubEvent()
unsubStatus()
unsubError()
unsubSkill()
unsubFocusTab()
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
chunkBufferRef.current.clear()
}
}, [handleNormalizedEvent, handleStatusChange, handleError])

// Note: window.clui.start() is called via sessionStore.initStaticInfo() in App.tsx.
// No duplicate call needed here.
// Sync notification mode to main process
const notificationMode = useThemeStore((s) => s.notificationMode)
useEffect(() => {
window.clui.setNotificationMode(notificationMode)
}, [notificationMode])

// Tell main process which tab is actively visible (for 'tab-hidden' mode)
const activeTabId = useSessionStore((s) => s.activeTabId)
const isExpanded = useSessionStore((s) => s.isExpanded)
useEffect(() => {
// A tab is "visible" if it's the active tab and the card is expanded
window.clui.setActiveTab(isExpanded ? activeTabId : null)
}, [activeTabId, isExpanded])
}
28 changes: 21 additions & 7 deletions src/renderer/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,22 @@ export type ColorPalette = { [K in keyof typeof darkColors]: string }

export type ThemeMode = 'system' | 'light' | 'dark'

/** When to show native OS notifications for task completion/errors. */
export type NotificationMode = 'always' | 'tab-hidden' | 'window-hidden'

interface ThemeState {
isDark: boolean
themeMode: ThemeMode
soundEnabled: boolean
expandedUI: boolean
notificationMode: NotificationMode
/** OS-reported dark mode — used when themeMode is 'system' */
_systemIsDark: boolean
setIsDark: (isDark: boolean) => void
setThemeMode: (mode: ThemeMode) => void
setSoundEnabled: (enabled: boolean) => void
setExpandedUI: (expanded: boolean) => void
setNotificationMode: (mode: NotificationMode) => void
/** Called by OS theme change listener — updates system value */
setSystemTheme: (isDark: boolean) => void
}
Expand All @@ -308,7 +313,7 @@ function applyTheme(isDark: boolean): void {

const SETTINGS_KEY = 'clui-settings'

function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean } {
function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; notificationMode: NotificationMode } {
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
Expand All @@ -317,24 +322,30 @@ function loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expanded
themeMode: ['light', 'dark'].includes(parsed.themeMode) ? parsed.themeMode : 'dark',
soundEnabled: typeof parsed.soundEnabled === 'boolean' ? parsed.soundEnabled : true,
expandedUI: typeof parsed.expandedUI === 'boolean' ? parsed.expandedUI : false,
notificationMode: ['always', 'tab-hidden', 'window-hidden'].includes(parsed.notificationMode) ? parsed.notificationMode : 'window-hidden',
}
}
} catch {}
return { themeMode: 'dark', soundEnabled: true, expandedUI: false }
return { themeMode: 'dark', soundEnabled: true, expandedUI: false, notificationMode: 'window-hidden' }
}

function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean }): void {
function saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean; notificationMode: NotificationMode }): void {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } catch {}
}

// Always start in compact UI mode on launch.
const saved = { ...loadSettings(), expandedUI: false }

function currentSettings(get: () => ThemeState) {
return { themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI, notificationMode: get().notificationMode }
}

export const useThemeStore = create<ThemeState>((set, get) => ({
isDark: saved.themeMode === 'dark' ? true : saved.themeMode === 'light' ? false : true,
themeMode: saved.themeMode,
soundEnabled: saved.soundEnabled,
expandedUI: saved.expandedUI,
notificationMode: saved.notificationMode,
_systemIsDark: true,
setIsDark: (isDark) => {
set({ isDark })
Expand All @@ -344,19 +355,22 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
const resolved = mode === 'system' ? get()._systemIsDark : mode === 'dark'
set({ themeMode: mode, isDark: resolved })
applyTheme(resolved)
saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI })
saveSettings({ ...currentSettings(get), themeMode: mode })
},
setSoundEnabled: (enabled) => {
set({ soundEnabled: enabled })
saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI })
saveSettings({ ...currentSettings(get), soundEnabled: enabled })
},
setExpandedUI: (expanded) => {
set({ expandedUI: expanded })
saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded })
saveSettings({ ...currentSettings(get), expandedUI: expanded })
},
setNotificationMode: (mode) => {
set({ notificationMode: mode })
saveSettings({ ...currentSettings(get), notificationMode: mode })
},
setSystemTheme: (isDark) => {
set({ _systemIsDark: isDark })
// Only apply if following system
if (get().themeMode === 'system') {
set({ isDark })
applyTheme(isDark)
Expand Down
5 changes: 5 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ export const IPC = {
// Permission mode
SET_PERMISSION_MODE: 'clui:set-permission-mode',

// Notifications
FOCUS_TAB: 'clui:focus-tab',
SET_NOTIFICATION_MODE: 'clui:set-notification-mode',
SET_ACTIVE_TAB: 'clui:set-active-tab',

// Legacy (kept for backward compat during migration)
STREAM_EVENT: 'clui:stream-event',
RUN_COMPLETE: 'clui:run-complete',
Expand Down