diff --git a/CLAUDE.md b/CLAUDE.md index cdfcf8f2..56548095 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,6 +389,23 @@ gh run view --log | tail -200 | grep -E "failed|passed|Test results|Sum ``` +## Slash Commands + +### /fixmac Command + +When the user types `/fixmac`, use the Task tool with the XcodeBuildMCP subagent to fix Mac compilation errors and warnings: + +``` +Task(description="Fix Mac build errors", prompt="/fixmac", subagent_type="general-purpose") +``` + +The agent will: +1. Use XcodeBuildMCP tools to identify build errors and warnings +2. Fix compilation issues in the Mac codebase +3. Address SwiftFormat violations +4. Resolve any warning messages +5. Verify the build succeeds after fixes + ## Key Files Quick Reference - Architecture Details: `docs/ARCHITECTURE.md` diff --git a/docs/push-impl.md b/docs/push-impl.md new file mode 100644 index 00000000..a0ecfae4 --- /dev/null +++ b/docs/push-impl.md @@ -0,0 +1,501 @@ +# Push Notification Implementation Plan + +This document outlines the comprehensive plan for improving VibeTunnel's notification system through two major initiatives: +1. Creating a dedicated Notifications tab in macOS settings +2. Migrating SessionMonitor from the Mac app to the server for unified notifications + +## Overview + +Currently, VibeTunnel has inconsistent notification implementations between the Mac and web clients. The Mac app has its own SessionMonitor while the web relies on server events. This leads to: +- Different notification behaviors between platforms +- Missing features (e.g., Claude Turn notifications not shown in web UI) +- Duplicate code and maintenance burden +- Inconsistent descriptions and thresholds + +## Part 1: macOS Settings Redesign + +### Current State +- Notification settings are cramped in the General tab +- No room for descriptive text explaining each notification type +- Settings are already at 710px height (quite tall) +- Missing helpful context that exists in the web UI + +### Proposed Solution: Dedicated Notifications Tab + +#### 1. Add Notifications Tab to SettingsTab enum + +```swift +// SettingsTab.swift +enum SettingsTab: String, CaseIterable { + case general + case notifications // NEW + case quickStart + case dashboard + // ... rest of tabs +} + +// Add display name and icon +var displayName: String { + switch self { + case .notifications: "Notifications" + // ... rest + } +} + +var icon: String { + switch self { + case .notifications: "bell.badge" + // ... rest + } +} +``` + +#### 2. Create NotificationSettingsView.swift + +```swift +struct NotificationSettingsView: View { + @ObservedObject private var configManager = ConfigManager.shared + @ObservedObject private var notificationService = NotificationService.shared + + var body: some View { + Form { + // Master toggle section + Section { + VStack(alignment: .leading, spacing: 8) { + Toggle("Show Session Notifications", isOn: $showNotifications) + Text("Display native macOS notifications for session and command events") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Notification types section + Section { + NotificationToggleRow( + title: "Session starts", + description: "When a new session starts (useful for shared terminals)", + isOn: $configManager.notificationSessionStart, + helpText: NotificationHelp.sessionStart + ) + + NotificationToggleRow( + title: "Session ends", + description: "When a session terminates or crashes (shows exit code)", + isOn: $configManager.notificationSessionExit, + helpText: NotificationHelp.sessionExit + ) + + // ... other notification types + } header: { + Text("Notification Types") + } + + // Behavior section + Section { + Toggle("Play sound", isOn: $configManager.notificationSoundEnabled) + Toggle("Show in Notification Center", isOn: $configManager.showInNotificationCenter) + } header: { + Text("Notification Behavior") + } + + // Test section + Section { + Button("Test Notification") { + notificationService.sendTestNotification() + } + } + } + } +} +``` + +#### 3. Create Reusable NotificationToggleRow Component + +```swift +struct NotificationToggleRow: View { + let title: String + let description: String + @Binding var isOn: Bool + let helpText: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle(title, isOn: $isOn) + .toggleStyle(.checkbox) + HelpTooltip(text: helpText) + } + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} +``` + +#### 4. Update SettingsView.swift + +```swift +// Add the new tab +NotificationSettingsView() + .tabItem { + Label(SettingsTab.notifications.displayName, + systemImage: SettingsTab.notifications.icon) + } + .tag(SettingsTab.notifications) +``` + +#### 5. Update GeneralSettingsView.swift + +Remove all notification-related settings to free up space. + +### Standardized Notification Descriptions + +Use these descriptions consistently across Mac and web: + +| Type | Title | Description | +|------|-------|-------------| +| Session Start | Session starts | When a new session starts (useful for shared terminals) | +| Session Exit | Session ends | When a session terminates or crashes (shows exit code) | +| Command Error | Commands fail | When commands fail with non-zero exit codes | +| Command Completion | Commands complete (> 3 seconds) | When commands taking >3 seconds finish (builds, tests, etc.) | +| Terminal Bell | Terminal bell (🔔) | Terminal bell (^G) from vim, IRC mentions, completion sounds | +| Claude Turn | Claude turn notifications | When Claude AI finishes responding and awaits input | + +## Part 2: Server-Side SessionMonitor Migration + +### Current Architecture + +``` +Mac App: + SessionMonitor (Swift) → NotificationService → macOS notifications + +Server: + PtyManager → Basic events → SSE → Web notifications +``` + +### Proposed Architecture + +``` +Server: + PtyManager → SessionMonitor (TypeScript) → Enhanced events → SSE/WebSocket + ↓ + Mac & Web clients +``` + +### Implementation Steps + +#### 1. Create Server-Side SessionMonitor + +```typescript +// web/src/server/services/session-monitor.ts + +export interface SessionState { + id: string; + name: string; + command: string[]; + isRunning: boolean; + activityStatus?: { + isActive: boolean; + lastActivity?: Date; + specificStatus?: { + app: string; + status: string; + }; + }; + commandStartTime?: Date; + lastCommand?: string; +} + +export class SessionMonitor { + private sessions = new Map(); + private claudeIdleNotified = new Set(); + private lastActivityState = new Map(); + private commandThresholdMs = 3000; // 3 seconds + + constructor( + private ptyManager: PtyManager, + private eventBus: EventEmitter + ) { + this.setupEventListeners(); + } + + private detectClaudeSession(session: SessionState): boolean { + const isClaudeCommand = session.command + .join(' ') + .toLowerCase() + .includes('claude'); + + const isClaudeApp = session.activityStatus?.specificStatus?.app + .toLowerCase() + .includes('claude') ?? false; + + return isClaudeCommand || isClaudeApp; + } + + private checkClaudeTurnNotification(sessionId: string, newState: SessionState) { + if (!this.detectClaudeSession(newState)) return; + + const currentActive = newState.activityStatus?.isActive ?? false; + const previousActive = this.lastActivityState.get(sessionId) ?? false; + + // Claude went from active to idle + if (previousActive && !currentActive && !this.claudeIdleNotified.has(sessionId)) { + this.eventBus.emit('notification', { + type: ServerEventType.ClaudeTurn, + sessionId, + sessionName: newState.name, + message: 'Claude has finished responding' + }); + this.claudeIdleNotified.add(sessionId); + } + + // Reset when Claude becomes active again + if (!previousActive && currentActive) { + this.claudeIdleNotified.delete(sessionId); + } + + this.lastActivityState.set(sessionId, currentActive); + } + + // ... other monitoring methods +} +``` + +#### 2. Enhance Event Types + +```typescript +// web/src/shared/types.ts + +export enum ServerEventType { + SessionStart = 'session-start', + SessionExit = 'session-exit', + CommandFinished = 'command-finished', + CommandError = 'command-error', // NEW - separate from finished + Bell = 'bell', // NEW + ClaudeTurn = 'claude-turn', + Connected = 'connected' +} + +export interface ServerEvent { + type: ServerEventType; + timestamp: string; + sessionId: string; + sessionName?: string; + + // Event-specific data + exitCode?: number; + command?: string; + duration?: number; + message?: string; + + // Activity status for richer client UI + activityStatus?: { + isActive: boolean; + app?: string; + }; +} +``` + +#### 3. Integrate with PtyManager + +```typescript +// web/src/server/pty/pty-manager.ts + +class PtyManager { + private sessionMonitor: SessionMonitor; + + constructor() { + this.sessionMonitor = new SessionMonitor(this, serverEventBus); + } + + // Feed data to SessionMonitor + private handlePtyData(sessionId: string, data: string) { + // Existing data handling... + + // Detect bell character + if (data.includes('\x07')) { + serverEventBus.emit('notification', { + type: ServerEventType.Bell, + sessionId, + sessionName: this.sessions.get(sessionId)?.name + }); + } + + // Update activity status + this.sessionMonitor.updateActivity(sessionId, { + isActive: true, + lastActivity: new Date() + }); + } +} +``` + +#### 4. Update Server Routes + +```typescript +// web/src/server/routes/events.ts + +// Enhanced event handling +serverEventBus.on('notification', (event: ServerEvent) => { + // Send to all connected SSE clients + broadcastEvent(event); + + // Log for debugging + logger.info(`📢 Notification event: ${event.type} for session ${event.sessionId}`); +}); +``` + +#### 5. Update Mac NotificationService + +```swift +// NotificationService.swift + +class NotificationService { + // Remove local SessionMonitor dependency + // Subscribe to server SSE events instead + + private func connectToServerEvents() { + eventSource = EventSource(url: "http://localhost:4020/api/events") + + eventSource.onMessage { event in + guard let data = event.data, + let serverEvent = try? JSONDecoder().decode(ServerEvent.self, from: data) else { + return + } + + Task { @MainActor in + self.handleServerEvent(serverEvent) + } + } + } + + private func handleServerEvent(_ event: ServerEvent) { + // Map server events to notifications + switch event.type { + case .sessionStart: + if preferences.sessionStart { + sendNotification(for: event) + } + // ... handle other event types + } + } +} +``` + +#### 6. Update Web Notification Service + +```typescript +// web/src/client/services/push-notification-service.ts + +// Add Claude Turn to notification handling +private handleServerEvent(event: ServerEvent) { + if (!this.preferences[this.mapEventTypeToPreference(event.type)]) { + return; + } + + // Send browser notification + this.showNotification(event); +} + +private mapEventTypeToPreference(type: ServerEventType): keyof NotificationPreferences { + const mapping = { + [ServerEventType.SessionStart]: 'sessionStart', + [ServerEventType.SessionExit]: 'sessionExit', + [ServerEventType.CommandFinished]: 'commandCompletion', + [ServerEventType.CommandError]: 'commandError', + [ServerEventType.Bell]: 'bell', + [ServerEventType.ClaudeTurn]: 'claudeTurn' // Now properly mapped + }; + return mapping[type]; +} +``` + +#### 7. Add Claude Turn to Web UI + +```typescript +// web/src/client/components/settings.ts + +// In notification types section +${this.renderNotificationToggle('claudeTurn', 'Claude Turn', + 'When Claude AI finishes responding and awaits input')} +``` + +## Migration Strategy + +### Phase 1: Preparation (Non-breaking) +1. Implement server-side SessionMonitor alongside existing system +2. Add new event types to shared types +3. Update web UI to show Claude Turn option + +### Phase 2: Server Enhancement (Non-breaking) +1. Deploy enhanced server with SessionMonitor +2. Server emits both old and new event formats +3. Test with web client to ensure compatibility + +### Phase 3: Mac App Migration +1. Update Mac app to consume server events +2. Keep fallback to local monitoring if server unavailable +3. Remove local SessionMonitor once stable + +### Phase 4: Cleanup +1. Remove old event formats from server +2. Remove local SessionMonitor code from Mac +3. Document new architecture + +## Testing Plan + +### Unit Tests +- SessionMonitor Claude detection logic +- Event threshold calculations +- Activity state transitions + +### Integration Tests +- Server events reach both Mac and web clients +- Notification preferences are respected +- Claude Turn notifications work correctly +- Bell character detection + +### Manual Testing +- Test each notification type on both platforms +- Verify descriptions match +- Test with multiple clients connected +- Test offline Mac app behavior + +## Success Metrics + +1. **Consistency**: Same notifications appear on Mac and web for same events +2. **Feature Parity**: Claude Turn available on both platforms +3. **Performance**: No noticeable lag in notifications +4. **Reliability**: No missed notifications +5. **Maintainability**: Single codebase for monitoring logic + +## Timeline Estimate + +- **Week 1**: Implement macOS Notifications tab +- **Week 2**: Create server-side SessionMonitor +- **Week 3**: Integrate and test with web client +- **Week 4**: Migrate Mac app and testing +- **Week 5**: Polish, documentation, and deployment + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking existing notifications | High | Phased rollout, maintain backwards compatibility | +| Performance impact on server | Medium | Efficient event handling, consider debouncing | +| Mac app offline mode | Medium | Keep local fallback for critical notifications | +| Complex migration | Medium | Detailed testing plan, feature flags | + +## Conclusion + +This two-part implementation will: +1. Provide a better UI for notification settings on macOS +2. Create a unified notification system across all platforms +3. Reduce code duplication and maintenance burden +4. Ensure consistent behavior for all users + +The migration is designed to be non-breaking with careful phases to minimize risk. \ No newline at end of file diff --git a/docs/push-notification.md b/docs/push-notification.md index 43399074..43ac02bb 100644 --- a/docs/push-notification.md +++ b/docs/push-notification.md @@ -6,12 +6,121 @@ VibeTunnel provides real-time alerts for terminal events via native macOS notifi The **Session Monitor** is the core of the notification system. It observes terminal sessions for key events and dispatches them to the appropriate notification service (macOS or web). -### Key Monitored Events -- **Session Start/Exit**: Get notified when a terminal session begins or ends. -- **Command Completion**: Alerts for long-running commands. -- **Errors**: Notifications for commands that fail. -- **Terminal Bell**: Triggered by programs sending a bell character (`^G`). -- **Claude "Your Turn"**: A special notification when Claude AI finishes a response and is awaiting your input. +### Notification Settings Explained + +When you enable notifications in VibeTunnel, you can choose which events to be notified about: + +#### 1. Session starts ✓ +- **Notification**: "Session Started" with the session name +- **Triggers when**: A new terminal session is created +- **Use case**: Know when someone starts using your shared terminal + +#### 2. Session ends ✓ +- **Notification**: "Session Ended" with the session name +- **Shows exit code**: If the session crashed or exited abnormally +- **Triggers when**: A terminal session closes +- **Use case**: Monitor when sessions terminate, especially if unexpected + +#### 3. Commands complete (> 3 seconds) ✓ +- **Notification**: "Your Turn" with the command that finished +- **Shows duration**: How long the command took to complete +- **Triggers when**: Any command that runs for more than 3 seconds finishes +- **Use case**: Get notified when long builds, tests, or scripts complete + +#### 4. Commands fail ✓ +- **Notification**: "Command Failed" with the failed command +- **Shows exit code**: The specific error code returned +- **Triggers when**: Any command returns a non-zero exit code +- **Use case**: Immediately know when something goes wrong + +#### 5. Terminal bell (\u{0007}) ✓ +- **Notification**: "Terminal Bell" with the session name +- **Triggers when**: A program outputs the bell character (ASCII 7/^G) +- **Common sources**: vim alerts, IRC mentions, completion sounds +- **Use case**: Get alerts from terminal programs that use the bell + +#### 6. Claude turn notifications ✓ +- **Notification**: "Your Turn" when Claude finishes responding +- **Smart detection**: Monitors Claude CLI sessions automatically +- **Triggers when**: Claude transitions from typing to waiting for input +- **How it works**: + - Detects sessions running Claude (by command or app name) + - Tracks when Claude stops outputting text + - Only notifies once per response (won't spam) +- **Use case**: Know when Claude has finished its response and needs your input + +## Architecture + +### System Overview + +The notification system in VibeTunnel follows a layered architecture: + +``` +Terminal Events → Session Monitor → Event Processing → Notification Service → OS/Browser +``` + +### Key Components + +#### 1. Session Monitor (`SessionMonitor.swift`) +- **Role**: Tracks all terminal sessions and their state changes +- **Key responsibilities**: + - Monitors session lifecycle (start/exit) + - Tracks command execution and duration + - Detects Claude CLI activity transitions + - Filters events based on thresholds (e.g., 3-second rule) + +#### 2. Server Event System (`ServerEvent.swift`) +- **Event types**: + - `sessionStart`, `sessionExit`: Session lifecycle + - `commandFinished`, `commandError`: Command execution + - `bell`: Terminal bell character detection + - `claudeTurn`: Claude AI idle detection +- **Event data**: Each event carries session ID, display name, duration, exit codes, etc. + +#### 3. Notification Service (`NotificationService.swift`) +- **macOS integration**: Uses `UserNotifications` framework +- **Event source**: Connects to server via SSE at `/api/events` +- **Preference management**: Checks user settings before sending +- **Permission handling**: Manages notification authorization + +#### 4. Configuration Manager (`ConfigManager.swift`) +- **Settings storage**: Persists user notification preferences +- **Default values**: All notification types enabled by default +- **Real-time updates**: Changes take effect immediately + +### Event Flow + +1. **Terminal Activity**: User runs commands, sessions start/stop +2. **Event Detection**: Session Monitor detects changes +3. **Event Creation**: Creates typed `ServerEvent` objects +4. **Filtering**: Checks user preferences and thresholds +5. **Notification Dispatch**: Sends to OS notification center +6. **User Interaction**: Shows native macOS notifications + +### Special Features + +#### Claude Detection Algorithm +```swift +// Detects Claude sessions by command or app name +let isClaudeSession = session.command.contains("claude") || + session.app.lowercased().contains("claude") + +// Tracks activity state transitions +if previousActive && !currentActive && !alreadyNotified { + // Claude went from typing to idle - send notification +} +``` + +#### Command Duration Tracking +- Only notifies for commands > 3 seconds +- Tracks start time when command begins +- Calculates duration on completion +- Formats duration for display (e.g., "5 seconds", "2 minutes") + +#### Bell Character Detection +- Terminal emulator detects ASCII 7 (`\u{0007}`) +- Forwards bell events through WebSocket +- Server converts to notification event ## Native macOS Notifications @@ -27,6 +136,38 @@ For non-macOS clients or remote access, VibeTunnel supports web push notificatio - **Enable**: Click the notification icon in the web UI and grant browser permission. - **Technology**: Uses Service Workers and the Web Push API. +### HTTPS Requirement + +⚠️ **Important**: Web push notifications require HTTPS to function. This is a security requirement enforced by all modern browsers. + +- **Local development**: Works on `http://localhost:4020` without HTTPS +- **Remote access**: Requires HTTPS with a valid SSL certificate +- **Why**: Service Workers (which power push notifications) only work on secure origins to prevent man-in-the-middle attacks + +### Enabling HTTPS for Remote Access + +If you need web push notifications when accessing VibeTunnel remotely, you'll need to serve it over HTTPS. Here are some solutions: + +#### Tailscale Serve (Recommended) +[Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve) is an excellent solution for automatically creating HTTPS connections within your network: + +```bash +# Install Tailscale and connect to your network +# Then expose VibeTunnel with HTTPS: +tailscale serve https / http://localhost:4020 +``` + +Benefits: +- Automatic HTTPS with valid certificates +- Works within your Tailscale network +- No port forwarding or firewall configuration needed +- Push notifications will work for all devices on your Tailnet + +#### Other Options +- **Ngrok**: Provides HTTPS tunnels but requires external exposure +- **Cloudflare Tunnel**: Free HTTPS tunneling service +- **Let's Encrypt**: For permanent HTTPS setup with your own domain + ## Troubleshooting - **No Notifications**: Ensure they are enabled in both VibeTunnel settings and your OS/browser settings. diff --git a/mac/VibeTunnel/Core/Services/ConfigManager.swift b/mac/VibeTunnel/Core/Services/ConfigManager.swift index 1e6c321f..de6e5458 100644 --- a/mac/VibeTunnel/Core/Services/ConfigManager.swift +++ b/mac/VibeTunnel/Core/Services/ConfigManager.swift @@ -47,6 +47,7 @@ final class ConfigManager { var notificationClaudeTurn: Bool = false var notificationSoundEnabled: Bool = true var notificationVibrationEnabled: Bool = true + var showInNotificationCenter: Bool = true // Remote access var ngrokEnabled: Bool = false @@ -107,6 +108,7 @@ final class ConfigManager { var claudeTurn: Bool var soundEnabled: Bool var vibrationEnabled: Bool + var showInNotificationCenter: Bool? } private struct RemoteAccessConfig: Codable { @@ -193,6 +195,9 @@ final class ConfigManager { self.notificationClaudeTurn = notif.claudeTurn self.notificationSoundEnabled = notif.soundEnabled self.notificationVibrationEnabled = notif.vibrationEnabled + if let showInCenter = notif.showInNotificationCenter { + self.showInNotificationCenter = showInCenter + } } } @@ -236,6 +241,7 @@ final class ConfigManager { self.notificationClaudeTurn = false self.notificationSoundEnabled = true self.notificationVibrationEnabled = true + self.showInNotificationCenter = true saveConfiguration() } @@ -281,7 +287,8 @@ final class ConfigManager { bell: notificationBell, claudeTurn: notificationClaudeTurn, soundEnabled: notificationSoundEnabled, - vibrationEnabled: notificationVibrationEnabled + vibrationEnabled: notificationVibrationEnabled, + showInNotificationCenter: showInNotificationCenter ) ) diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index b8b1e56d..76e3559b 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import Observation import os.log @preconcurrency import UserNotifications @@ -8,6 +9,7 @@ import os.log /// Connects to the VibeTunnel server to receive real-time events like session starts, /// command completions, and errors, then displays them as native macOS notifications. @MainActor +@Observable final class NotificationService: NSObject { @MainActor static let shared = NotificationService() @@ -730,9 +732,7 @@ final class NotificationService: NSObject { deinit { // Note: We can't call disconnect() here because it's @MainActor isolated // The cleanup will happen when the EventSource is deallocated - eventSource?.disconnect() - eventSource = nil - NotificationCenter.default.removeObserver(self) + // NotificationCenter observers are automatically removed on deinit in modern Swift } } diff --git a/mac/VibeTunnel/Presentation/Components/HelpTooltip.swift b/mac/VibeTunnel/Presentation/Components/HelpTooltip.swift new file mode 100644 index 00000000..f8bda3ac --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/HelpTooltip.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// A help icon with tooltip for explaining settings +struct HelpTooltip: View { + let text: String + @State private var isHovering = false + + var body: some View { + Image(systemName: "questionmark.circle") + .foregroundColor(.secondary) + .imageScale(.small) + .help(text) + .onHover { hovering in + isHovering = hovering + } + .scaleEffect(isHovering ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isHovering) + } +} + +/// Notification setting descriptions +enum NotificationHelp { + static let sessionStarts = "Get notified when a new terminal session begins. Useful for monitoring when someone starts using your shared terminal." + + static let sessionEnds = "Get notified when a terminal session closes. Shows exit code if the session crashed or exited abnormally." + + static let commandsComplete = "Get notified when commands that take longer than 3 seconds finish. Perfect for long builds, tests, or data processing tasks." + + static let commandsFail = "Get notified when any command exits with an error (non-zero exit code). Helps you quickly spot and fix problems." + + static let terminalBell = "Get notified when programs output the terminal bell character (^G). Common in vim alerts, IRC mentions, and completion notifications." + + static let claudeTurn = "Get notified when Claude AI finishes responding and is waiting for your input. Automatically detects Claude CLI sessions and tracks activity transitions." +} + +#Preview { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("Session starts") + HelpTooltip(text: NotificationHelp.sessionStarts) + } + + HStack { + Text("Commands complete (> 3 seconds)") + HelpTooltip(text: NotificationHelp.commandsComplete) + } + + HStack { + Text("Claude turn notifications") + HelpTooltip(text: NotificationHelp.claudeTurn) + } + } + .padding() +} diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index a4f6f5d7..9f3e1c30 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -6,8 +6,6 @@ import SwiftUI struct GeneralSettingsView: View { @AppStorage("autostart") private var autostart = false - @AppStorage("showNotifications") - private var showNotifications = true @AppStorage(AppConstants.UserDefaultsKeys.updateChannel) private var updateChannelRaw = UpdateChannel.stable.rawValue @AppStorage(AppConstants.UserDefaultsKeys.showInDock) @@ -16,8 +14,10 @@ struct GeneralSettingsView: View { private var preventSleepWhenRunning = true @Environment(ConfigManager.self) private var configManager + @Environment(SystemPermissionManager.self) private var permissionManager @State private var isCheckingForUpdates = false + @State private var permissionUpdateTrigger = 0 private let startupManager = StartupManager() private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings") @@ -26,10 +26,20 @@ struct GeneralSettingsView: View { UpdateChannel(rawValue: updateChannelRaw) ?? .stable } - private func updateNotificationPreferences() { - // Load current preferences from ConfigManager and notify the service - let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) - NotificationService.shared.updatePreferences(prefs) + // MARK: - Helper Properties + + // IMPORTANT: These computed properties ensure the UI always shows current permission state. + // The permissionUpdateTrigger dependency forces SwiftUI to re-evaluate these properties + // when permissions change. Without this, the UI would not update when permissions are + // granted in System Settings while this view is visible. + private var hasAppleScriptPermission: Bool { + _ = permissionUpdateTrigger + return permissionManager.hasPermission(.appleScript) + } + + private var hasAccessibilityPermission: Bool { + _ = permissionUpdateTrigger + return permissionManager.hasPermission(.accessibility) } var body: some View { @@ -66,114 +76,6 @@ struct GeneralSettingsView: View { } } - // Show Session Notifications - VStack(alignment: .leading, spacing: 4) { - Toggle("Show Session Notifications", isOn: $showNotifications) - .onChange(of: showNotifications) { _, newValue in - // Ensure NotificationService starts/stops based on the toggle - if newValue { - Task { - // Request permissions and show test notification - let granted = await NotificationService.shared - .requestPermissionAndShowTestNotification() - - if granted { - await NotificationService.shared.start() - } else { - // If permission denied, turn toggle back off - await MainActor.run { - showNotifications = false - - // Show alert explaining the situation - let alert = NSAlert() - alert.messageText = "Notification Permission Required" - alert.informativeText = "VibeTunnel needs permission to show notifications. Please enable notifications for VibeTunnel in System Settings." - alert.alertStyle = .informational - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Cancel") - - if alert.runModal() == .alertFirstButtonReturn { - // Settings will already be open from the service - } - } - } - } - } else { - NotificationService.shared.stop() - } - } - Text("Display native macOS notifications for session and command events.") - .font(.caption) - .foregroundStyle(.secondary) - - if showNotifications { - VStack(alignment: .leading, spacing: 6) { - Text("Notify me for:") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 20) - .padding(.top, 4) - - VStack(alignment: .leading, spacing: 4) { - Toggle("Session starts", isOn: Binding( - get: { configManager.notificationSessionStart }, - set: { newValue in - configManager.notificationSessionStart = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Session ends", isOn: Binding( - get: { configManager.notificationSessionExit }, - set: { newValue in - configManager.notificationSessionExit = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Commands complete (> 3 seconds)", isOn: Binding( - get: { configManager.notificationCommandCompletion }, - set: { newValue in - configManager.notificationCommandCompletion = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Commands fail", isOn: Binding( - get: { configManager.notificationCommandError }, - set: { newValue in - configManager.notificationCommandError = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Terminal bell (\u{0007})", isOn: Binding( - get: { configManager.notificationBell }, - set: { newValue in - configManager.notificationBell = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - - Toggle("Claude turn notifications", isOn: Binding( - get: { configManager.notificationClaudeTurn }, - set: { newValue in - configManager.notificationClaudeTurn = newValue - updateNotificationPreferences() - } - )) - .toggleStyle(.checkbox) - } - .padding(.leading, 20) - } - } - } - // Prevent Sleep VStack(alignment: .leading, spacing: 4) { Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning) @@ -185,6 +87,13 @@ struct GeneralSettingsView: View { Text("Application") .font(.headline) } + + // System Permissions section (moved from Security) + PermissionsSection( + hasAppleScriptPermission: hasAppleScriptPermission, + hasAccessibilityPermission: hasAccessibilityPermission, + permissionManager: permissionManager + ) } .formStyle(.grouped) .scrollContentBackground(.hidden) @@ -193,6 +102,19 @@ struct GeneralSettingsView: View { .task { // Sync launch at login status autostart = startupManager.isLaunchAtLoginEnabled + // Check permissions before first render to avoid UI flashing + await permissionManager.checkAllPermissions() + } + .onAppear { + // Register for continuous monitoring + permissionManager.registerForMonitoring() + } + .onDisappear { + permissionManager.unregisterFromMonitoring() + } + .onReceive(NotificationCenter.default.publisher(for: .permissionsUpdated)) { _ in + // Increment trigger to force computed property re-evaluation + permissionUpdateTrigger += 1 } } @@ -243,3 +165,115 @@ struct GeneralSettingsView: View { } } } + +// MARK: - Permissions Section + +private struct PermissionsSection: View { + let hasAppleScriptPermission: Bool + let hasAccessibilityPermission: Bool + let permissionManager: SystemPermissionManager + + var body: some View { + Section { + // Automation permission + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Terminal Automation") + .font(.body) + Text("Required to launch and control terminal applications.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if hasAppleScriptPermission { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Granted") + .foregroundColor(.secondary) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .frame(height: 22) // Match small button height + .contextMenu { + Button("Refresh Status") { + permissionManager.forcePermissionRecheck() + } + Button("Open System Settings...") { + permissionManager.requestPermission(.appleScript) + } + } + } else { + Button("Grant Permission") { + permissionManager.requestPermission(.appleScript) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + // Accessibility permission + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Accessibility") + .font(.body) + Text("Required to enter terminal startup commands.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if hasAccessibilityPermission { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Granted") + .foregroundColor(.secondary) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .frame(height: 22) // Match small button height + .contextMenu { + Button("Refresh Status") { + permissionManager.forcePermissionRecheck() + } + Button("Open System Settings...") { + permissionManager.requestPermission(.accessibility) + } + } + } else { + Button("Grant Permission") { + permissionManager.requestPermission(.accessibility) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } header: { + Text("System Permissions") + .font(.headline) + } footer: { + if hasAppleScriptPermission && hasAccessibilityPermission { + Text( + "All permissions granted. VibeTunnel has full functionality." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .foregroundColor(.green) + } else { + Text( + "Terminals can be captured without permissions, however new sessions won't load." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + } +} diff --git a/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift new file mode 100644 index 00000000..63bde4cd --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift @@ -0,0 +1,241 @@ +import AppKit +import os.log +import SwiftUI + +private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "NotificationSettings") + +/// Settings view for managing notification preferences +struct NotificationSettingsView: View { + @AppStorage("showNotifications") + private var showNotifications = true + + @Environment(ConfigManager.self) private var configManager + @Environment(NotificationService.self) private var notificationService + + @State private var isTestingNotification = false + @State private var showingPermissionAlert = false + + private func updateNotificationPreferences() { + // Load current preferences from ConfigManager and notify the service + let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) + notificationService.updatePreferences(prefs) + // Also update the enabled state in ConfigManager + configManager.notificationsEnabled = showNotifications + } + + var body: some View { + NavigationStack { + @Bindable var bindableConfig = configManager + + Form { + // Master toggle section + Section { + VStack(alignment: .leading, spacing: 12) { + Toggle("Show Session Notifications", isOn: $showNotifications) + .controlSize(.large) + .onChange(of: showNotifications) { _, newValue in + // Update ConfigManager's notificationsEnabled to match + configManager.notificationsEnabled = newValue + + // Ensure NotificationService starts/stops based on the toggle + if newValue { + Task { + // Request permissions and show test notification + let granted = await notificationService + .requestPermissionAndShowTestNotification() + + if granted { + await notificationService.start() + } else { + // If permission denied, turn toggle back off + await MainActor.run { + showNotifications = false + configManager.notificationsEnabled = false + showingPermissionAlert = true + } + } + } + } else { + notificationService.stop() + } + } + Text("Display native macOS notifications for session and command events") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + + // Notification types section + if showNotifications { + Section { + NotificationToggleRow( + title: "Session starts", + description: "When a new session starts (useful for shared terminals)", + isOn: $bindableConfig.notificationSessionStart + ) + .onChange(of: bindableConfig.notificationSessionStart) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Session ends", + description: "When a session terminates or crashes (shows exit code)", + isOn: $bindableConfig.notificationSessionExit + ) + .onChange(of: bindableConfig.notificationSessionExit) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Commands fail", + description: "When commands fail with non-zero exit codes", + isOn: $bindableConfig.notificationCommandError + ) + .onChange(of: bindableConfig.notificationCommandError) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Commands complete (> 3 seconds)", + description: "When commands taking >3 seconds finish (builds, tests, etc.)", + isOn: $bindableConfig.notificationCommandCompletion + ) + .onChange(of: bindableConfig.notificationCommandCompletion) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Terminal bell (🔔)", + description: "Terminal bell (^G) from vim, IRC mentions, completion sounds", + isOn: $bindableConfig.notificationBell + ) + .onChange(of: bindableConfig.notificationBell) { _, _ in + updateNotificationPreferences() + } + + NotificationToggleRow( + title: "Claude turn notifications", + description: "When Claude AI finishes responding and awaits input", + isOn: $bindableConfig.notificationClaudeTurn + ) + .onChange(of: bindableConfig.notificationClaudeTurn) { _, _ in + updateNotificationPreferences() + } + } header: { + Text("Notification Types") + .font(.headline) + } + + // Behavior section + Section { + VStack(spacing: 12) { + Toggle("Play sound", isOn: $bindableConfig.notificationSoundEnabled) + .onChange(of: bindableConfig.notificationSoundEnabled) { _, _ in + updateNotificationPreferences() + } + + Toggle("Show in Notification Center", isOn: $bindableConfig.showInNotificationCenter) + .onChange(of: bindableConfig.showInNotificationCenter) { _, _ in + updateNotificationPreferences() + } + } + } header: { + Text("Notification Behavior") + .font(.headline) + } + + // Test section + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + Button("Test Notification") { + Task { + isTestingNotification = true + await notificationService.sendGenericNotification( + title: "VibeTunnel Test", + body: "This is a test notification to verify your settings are working correctly." + ) + // Reset button state after a delay + try? await Task.sleep(nanoseconds: 1_000_000_000) + isTestingNotification = false + } + } + .buttonStyle(.bordered) + .disabled(!showNotifications || isTestingNotification) + + if isTestingNotification { + ProgressView() + .scaleEffect(0.7) + .frame(width: 16, height: 16) + } + + Spacer() + } + + HStack { + Button("Open System Settings") { + notificationService.openNotificationSettings() + } + .buttonStyle(.link) + + Spacer() + } + } + } header: { + Text("Actions") + .font(.headline) + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .navigationTitle("Notification Settings") + .onAppear { + // Sync the AppStorage value with ConfigManager on first load + showNotifications = configManager.notificationsEnabled + } + } + .alert("Notification Permission Required", isPresented: $showingPermissionAlert) { + Button("Open System Settings") { + notificationService.openNotificationSettings() + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "VibeTunnel needs permission to show notifications. Please enable notifications for VibeTunnel in System Settings." + ) + } + } +} + +/// Reusable component for notification toggle rows with descriptions +struct NotificationToggleRow: View { + let title: String + let description: String + @Binding var isOn: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.body) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Toggle("", isOn: $isOn) + .labelsHidden() + } + .padding(.vertical, 6) + } +} + +#Preview { + NotificationSettingsView() + .environment(ConfigManager.shared) + .environment(NotificationService.shared) + .frame(width: 560, height: 700) +} diff --git a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift index 83469072..e4c71947 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift @@ -12,6 +12,10 @@ struct RemoteAccessSettingsView: View { private var serverPort = "4020" @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) private var accessModeString = AppConstants.Defaults.dashboardAccessMode + @AppStorage(AppConstants.UserDefaultsKeys.authenticationMode) + private var authModeString = "os" + + @State private var authMode: AuthenticationMode = .osAuth @Environment(NgrokService.self) private var ngrokService @@ -43,6 +47,14 @@ struct RemoteAccessSettingsView: View { var body: some View { NavigationStack { Form { + // Authentication section (moved from Security) + AuthenticationSection( + authMode: $authMode, + enableSSHKeys: .constant(authMode == .sshKeys || authMode == .both), + logger: logger, + serverManager: serverManager + ) + TailscaleIntegrationSection( tailscaleService: tailscaleService, serverPort: serverPort, @@ -78,6 +90,9 @@ struct RemoteAccessSettingsView: View { .onAppear { onAppearSetup() updateLocalIPAddress() + // Initialize authentication mode from stored value + let storedMode = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.authenticationMode) ?? "os" + authMode = AuthenticationMode(rawValue: storedMode) ?? .osAuth } } .alert("ngrok Authentication Required", isPresented: $showingAuthTokenAlert) { @@ -525,6 +540,108 @@ private struct ErrorView: View { } } +// MARK: - Authentication Section + +private struct AuthenticationSection: View { + @Binding var authMode: AuthenticationMode + @Binding var enableSSHKeys: Bool + let logger: Logger + let serverManager: ServerManager + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 16) { + // Authentication mode picker + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Authentication Method") + .font(.callout) + Spacer() + Picker("", selection: $authMode) { + ForEach(AuthenticationMode.allCases, id: \.self) { mode in + Text(mode.displayName) + .tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(alignment: .trailing) + .onChange(of: authMode) { _, newValue in + // Save the authentication mode + UserDefaults.standard.set( + newValue.rawValue, + forKey: AppConstants.UserDefaultsKeys.authenticationMode + ) + + Task { + logger.info("Authentication mode changed to: \(newValue.rawValue)") + await serverManager.restart() + } + } + } + + Text(authMode.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Additional info based on selected mode + if authMode == .osAuth || authMode == .both { + HStack(alignment: .center, spacing: 6) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + .font(.system(size: 12)) + .frame(width: 16, height: 16) + Text("Uses your macOS username: \(NSUserName())") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + } + + if authMode == .sshKeys || authMode == .both { + HStack(alignment: .center, spacing: 6) { + Image(systemName: "key.fill") + .foregroundColor(.blue) + .font(.system(size: 12)) + .frame(width: 16, height: 16) + Text("SSH keys from ~/.ssh/authorized_keys") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Open folder") { + let sshPath = NSHomeDirectory() + "/.ssh" + if FileManager.default.fileExists(atPath: sshPath) { + NSWorkspace.shared.open(URL(fileURLWithPath: sshPath)) + } else { + // Create .ssh directory if it doesn't exist + try? FileManager.default.createDirectory( + atPath: sshPath, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + NSWorkspace.shared.open(URL(fileURLWithPath: sshPath)) + } + } + .buttonStyle(.link) + .font(.caption) + } + } + } + } header: { + Text("Authentication") + .font(.headline) + } footer: { + Text("Localhost connections are always accessible without authentication.") + .font(.caption) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } +} + // MARK: - Previews #Preview("Remote Access Settings") { diff --git a/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift b/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift index 70257116..b3221df8 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/SettingsTab.swift @@ -6,10 +6,10 @@ import Foundation /// with associated display names and SF Symbol icons for the tab bar. enum SettingsTab: String, CaseIterable { case general + case notifications case quickStart case dashboard case remoteAccess - case securityPermissions case advanced case debug case about @@ -17,10 +17,10 @@ enum SettingsTab: String, CaseIterable { var displayName: String { switch self { case .general: "General" + case .notifications: "Notifications" case .quickStart: "Quick Start" case .dashboard: "Dashboard" case .remoteAccess: "Remote" - case .securityPermissions: "Security" case .advanced: "Advanced" case .debug: "Debug" case .about: "About" @@ -30,10 +30,10 @@ enum SettingsTab: String, CaseIterable { var icon: String { switch self { case .general: "gear" + case .notifications: "bell.badge" case .quickStart: "bolt.fill" case .dashboard: "server.rack" case .remoteAccess: "network" - case .securityPermissions: "lock.shield" case .advanced: "gearshape.2" case .debug: "hammer" case .about: "info.circle" diff --git a/mac/VibeTunnel/Presentation/Views/SettingsView.swift b/mac/VibeTunnel/Presentation/Views/SettingsView.swift index d0f59937..2fdec8aa 100644 --- a/mac/VibeTunnel/Presentation/Views/SettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/SettingsView.swift @@ -14,17 +14,17 @@ struct SettingsView: View { // MARK: - Constants private enum Layout { - static let defaultTabSize = CGSize(width: 520, height: 710) - static let fallbackTabSize = CGSize(width: 520, height: 450) + static let defaultTabSize = CGSize(width: 550, height: 710) + static let fallbackTabSize = CGSize(width: 550, height: 450) } /// Define ideal sizes for each tab private let tabSizes: [SettingsTab: CGSize] = [ .general: Layout.defaultTabSize, + .notifications: Layout.defaultTabSize, .quickStart: Layout.defaultTabSize, .dashboard: Layout.defaultTabSize, .remoteAccess: Layout.defaultTabSize, - .securityPermissions: Layout.defaultTabSize, .advanced: Layout.defaultTabSize, .debug: Layout.defaultTabSize, .about: Layout.defaultTabSize @@ -38,6 +38,12 @@ struct SettingsView: View { } .tag(SettingsTab.general) + NotificationSettingsView() + .tabItem { + Label(SettingsTab.notifications.displayName, systemImage: SettingsTab.notifications.icon) + } + .tag(SettingsTab.notifications) + QuickStartSettingsView() .tabItem { Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon) @@ -56,15 +62,6 @@ struct SettingsView: View { } .tag(SettingsTab.remoteAccess) - SecurityPermissionsSettingsView() - .tabItem { - Label( - SettingsTab.securityPermissions.displayName, - systemImage: SettingsTab.securityPermissions.icon - ) - } - .tag(SettingsTab.securityPermissions) - AdvancedSettingsView() .tabItem { Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon) diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 9d95ede9..983c9b70 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -27,6 +27,7 @@ struct VibeTunnelApp: App { @State var sessionService: SessionService? @State var worktreeService = WorktreeService(serverManager: ServerManager.shared) @State var configManager = ConfigManager.shared + @State var notificationService = NotificationService.shared init() { // Connect the app delegate to this app instance @@ -57,6 +58,7 @@ struct VibeTunnelApp: App { .environment(repositoryDiscoveryService) .environment(configManager) .environment(worktreeService) + .environment(notificationService) } .windowResizability(.contentSize) .defaultSize(width: 580, height: 480) @@ -83,6 +85,7 @@ struct VibeTunnelApp: App { sessionMonitor: sessionMonitor )) .environment(worktreeService) + .environment(notificationService) } else { Text("Session not found") .frame(width: 400, height: 300) @@ -109,6 +112,7 @@ struct VibeTunnelApp: App { sessionMonitor: sessionMonitor )) .environment(worktreeService) + .environment(notificationService) } .commands { CommandGroup(after: .appInfo) { diff --git a/web/src/client/components/session-view/session-header.ts b/web/src/client/components/session-view/session-header.ts index ef15ee74..b39069e0 100644 --- a/web/src/client/components/session-view/session-header.ts +++ b/web/src/client/components/session-view/session-header.ts @@ -322,23 +322,6 @@ export class SessionHeader extends LitElement {
- - ${ - this.hasGitRepo - ? html` - - ` - : '' - }
+ + ${ + this.hasGitRepo + ? html` + + ` + : '' + } +

Notification Types

- ${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates')} - ${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts')} - ${this.renderNotificationToggle('commandError', 'Session Errors', 'When errors occur in sessions')} - ${this.renderNotificationToggle('commandCompletion', 'Command Completion', 'When long-running commands finish')} - ${this.renderNotificationToggle('bell', 'System Alerts', 'Important system notifications')} + ${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates or crashes (shows exit code)')} + ${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts (useful for shared terminals)')} + ${this.renderNotificationToggle('commandError', 'Session Errors', 'When commands fail with non-zero exit codes')} + ${this.renderNotificationToggle('commandCompletion', 'Command Completion', 'When commands taking >3 seconds finish (builds, tests, etc.)')} + ${this.renderNotificationToggle('bell', 'System Alerts', 'Terminal bell (^G) from vim, IRC mentions, completion sounds')} + ${this.renderNotificationToggle('claudeTurn', 'Claude Turn', 'When Claude AI finishes responding and awaits input')}
@@ -496,8 +497,8 @@ export class Settings extends LitElement {

Notification Behavior

- ${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play sound with notifications')} - ${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications')} + ${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play a notification sound when alerts are triggered')} + ${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications (mobile devices only)')}
diff --git a/web/src/client/components/terminal-quick-keys.ts b/web/src/client/components/terminal-quick-keys.ts index ff3f15c8..0176c9d2 100644 --- a/web/src/client/components/terminal-quick-keys.ts +++ b/web/src/client/components/terminal-quick-keys.ts @@ -287,6 +287,10 @@ export class TerminalQuickKeys extends LitElement { /* Smooth transition when keyboard appears/disappears */ transition: bottom 0.3s ease-out; background-color: rgb(var(--color-bg-secondary)); + /* Prevent horizontal overflow */ + width: 100%; + max-width: 100vw; + overflow-x: hidden; } /* The actual bar with buttons */ @@ -299,6 +303,9 @@ export class TerminalQuickKeys extends LitElement { appearance: none; /* Add shadow for visibility */ box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5); + /* Ensure proper width */ + width: 100%; + box-sizing: border-box; } /* Quick key buttons */ @@ -448,7 +455,7 @@ export class TerminalQuickKeys extends LitElement { >
-
+
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map( ({ key, label, modifier, arrow, toggle }) => html`