Fix duplicate Git worktree button in mobile view

- Move worktree toggle button inside responsive container
- Button now properly hides when compact menu is shown
- Prevents redundant display of same functionality on mobile
This commit is contained in:
Peter Steinberger 2025-07-28 13:24:17 +02:00
parent 9bc3c7b891
commit a2bd642053
20 changed files with 1777 additions and 171 deletions

View file

@ -389,6 +389,23 @@ gh run view <run-id> --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`

501
docs/push-impl.md Normal file
View file

@ -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<string, SessionState>();
private claudeIdleNotified = new Set<string>();
private lastActivityState = new Map<string, boolean>();
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.

View file

@ -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.

View file

@ -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
)
)

View file

@ -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
}
}

View file

@ -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()
}

View file

@ -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)
}
}
}
}

View file

@ -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)
}

View file

@ -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") {

View file

@ -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"

View file

@ -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)

View file

@ -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) {

View file

@ -322,23 +322,6 @@ export class SessionHeader extends LitElement {
</div>
</div>
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
<!-- Git worktree toggle button (visible when session has Git repo) -->
${
this.hasGitRepo
? html`
<button
class="bg-bg-tertiary border border-border rounded-md p-2 text-primary transition-all duration-200 hover:bg-surface-hover hover:border-primary flex-shrink-0"
@click=${() => this.onToggleViewMode?.()}
title="${this.viewMode === 'terminal' ? 'Show Worktrees' : 'Show Terminal'}"
data-testid="worktree-toggle-button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
</svg>
</button>
`
: ''
}
<!-- Keyboard capture indicator (always visible) -->
<keyboard-capture-indicator
.active=${this.keyboardCaptureActive}
@ -385,6 +368,24 @@ export class SessionHeader extends LitElement {
: html`
<!-- Individual buttons for larger screens -->
<div class="flex items-center gap-2">
<!-- Git worktree toggle button (visible when session has Git repo) -->
${
this.hasGitRepo
? html`
<button
class="bg-bg-tertiary border border-border rounded-md p-2 text-primary transition-all duration-200 hover:bg-surface-hover hover:border-primary flex-shrink-0"
@click=${() => this.onToggleViewMode?.()}
title="${this.viewMode === 'terminal' ? 'Show Worktrees' : 'Show Terminal'}"
data-testid="worktree-toggle-button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
</svg>
</button>
`
: ''
}
<!-- Status dropdown -->
<session-status-dropdown
.session=${this.session}

View file

@ -484,11 +484,12 @@ export class Settings extends LitElement {
<div>
<h4 class="text-sm font-medium text-muted mb-3">Notification Types</h4>
<div class="space-y-2 bg-base rounded-lg p-3">
${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')}
</div>
</div>
@ -496,8 +497,8 @@ export class Settings extends LitElement {
<div>
<h4 class="text-sm font-medium text-muted mb-3">Notification Behavior</h4>
<div class="space-y-2 bg-base rounded-lg p-3">
${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)')}
</div>
</div>
</div>

View file

@ -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 {
>
<div class="quick-keys-bar">
<!-- Row 1 -->
<div class="flex gap-0.5 justify-center mb-0.5">
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map(
({ key, label, modifier, arrow, toggle }) => html`
<button
@ -500,7 +507,7 @@ export class TerminalQuickKeys extends LitElement {
this.showCtrlKeys
? html`
<!-- Ctrl shortcuts row -->
<div class="flex gap-0.5 justify-between flex-wrap mb-0.5">
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
${CTRL_SHORTCUTS.map(
({ key, label, combo, special }) => html`
<button
@ -535,7 +542,7 @@ export class TerminalQuickKeys extends LitElement {
: this.showFunctionKeys
? html`
<!-- Function keys row -->
<div class="flex gap-0.5 justify-between mb-0.5">
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
${FUNCTION_KEYS.map(
({ key, label }) => html`
<button
@ -569,7 +576,7 @@ export class TerminalQuickKeys extends LitElement {
`
: html`
<!-- Regular row 2 -->
<div class="flex gap-0.5 justify-center mb-0.5">
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
({ key, label, modifier, combo, special, toggle }) => html`
<button
@ -608,7 +615,7 @@ export class TerminalQuickKeys extends LitElement {
}
<!-- Row 3 - Additional special characters (always visible) -->
<div class="flex gap-0.5 justify-center">
<div class="flex gap-0.5 justify-center flex-wrap">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
({ key, label, modifier, combo, special }) => html`
<button

View file

@ -1489,7 +1489,10 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
/* Position keyboard button */
.keyboard-button {
@apply fixed bottom-3 right-3 z-50;
@apply fixed z-50;
/* Account for safe areas on mobile devices */
bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
right: calc(0.75rem + env(safe-area-inset-right, 0px));
/* Ensure button is always touchable */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;

View file

@ -28,6 +28,7 @@ import type {
} from '../../shared/types.js';
import { TitleMode } from '../../shared/types.js';
import { ProcessTreeAnalyzer } from '../services/process-tree-analyzer.js';
import type { SessionMonitor } from '../services/session-monitor.js';
import { ActivityDetector, type ActivityState } from '../utils/activity-detector.js';
import { TitleSequenceFilter } from '../utils/ansi-title-filter.js';
import { createLogger } from '../utils/logger.js';
@ -137,6 +138,7 @@ export class PtyManager extends EventEmitter {
private processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification
private activityFileWarningsLogged = new Set<string>(); // Track which sessions we've logged warnings for
private lastWrittenActivityState = new Map<string, string>(); // Track last written activity state to avoid unnecessary writes
private sessionMonitor: SessionMonitor | null = null; // Reference to SessionMonitor for notification tracking
// Command tracking for notifications
private commandTracking = new Map<
@ -181,6 +183,13 @@ export class PtyManager extends EventEmitter {
}
}
/**
* Set the SessionMonitor instance for activity tracking
*/
public setSessionMonitor(monitor: SessionMonitor): void {
this.sessionMonitor = monitor;
}
/**
* Setup terminal resize detection for when the hosting terminal is resized
*/
@ -708,6 +717,11 @@ export class PtyManager extends EventEmitter {
ptyProcess.onData((data: string) => {
let processedData = data;
// Track PTY output in SessionMonitor for activity and bell detection
if (this.sessionMonitor) {
this.sessionMonitor.trackPtyOutput(session.id, data);
}
// If title mode is not NONE, filter out any title sequences the process might
// have written to the stream.
if (session.titleMode !== undefined && session.titleMode !== TitleMode.NONE) {
@ -723,6 +737,16 @@ export class PtyManager extends EventEmitter {
if (activity.specificStatus?.status !== session.lastActivityStatus) {
session.lastActivityStatus = activity.specificStatus?.status;
this.markTitleUpdateNeeded(session);
// Update SessionMonitor with activity change
if (this.sessionMonitor) {
const isActive = activity.specificStatus?.status === 'working';
this.sessionMonitor.updateSessionActivity(
session.id,
isActive,
activity.specificStatus?.app
);
}
}
}
@ -2456,6 +2480,11 @@ export class PtyManager extends EventEmitter {
session.currentCommand = commandProc.command;
session.commandStartTime = Date.now();
// Update SessionMonitor with new command
if (this.sessionMonitor) {
this.sessionMonitor.updateCommand(session.id, commandProc.command);
}
// Special logging for Claude commands
const isClaudeCommand = commandProc.command.toLowerCase().includes('claude');
if (isClaudeCommand) {

View file

@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
import { type Request, type Response, Router } from 'express';
import { type ServerEvent, ServerEventType } from '../../shared/types.js';
import type { PtyManager } from '../pty/pty-manager.js';
import type { SessionMonitor } from '../services/session-monitor.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('events');
@ -12,7 +13,10 @@ export const serverEventBus = new EventEmitter();
/**
* Server-Sent Events (SSE) endpoint for real-time event streaming
*/
export function createEventsRouter(ptyManager: PtyManager): Router {
export function createEventsRouter(
ptyManager: PtyManager,
sessionMonitor?: SessionMonitor
): Router {
const router = Router();
// SSE endpoint for event streaming
@ -47,6 +51,7 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
let onCommandFinished: (data: CommandFinishedEvent) => void;
// biome-ignore lint/style/useConst: These are assigned later in the code
let onClaudeTurn: (sessionId: string, sessionName: string) => void;
let onNotification: (event: ServerEvent) => void;
// Cleanup function to remove event listeners
const cleanup = () => {
@ -57,6 +62,9 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
ptyManager.off('sessionExited', onSessionExited);
ptyManager.off('commandFinished', onCommandFinished);
ptyManager.off('claudeTurn', onClaudeTurn);
if (sessionMonitor) {
sessionMonitor.off('notification', onNotification);
}
};
// Send initial connection event
@ -166,6 +174,26 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
});
};
// Handle SessionMonitor notification events
if (sessionMonitor) {
onNotification = (event: ServerEvent) => {
// SessionMonitor already provides properly formatted ServerEvent objects
logger.info(`📢 SessionMonitor notification: ${event.type} for session ${event.sessionId}`);
// Proper SSE format with id, event, and data fields
const sseMessage = `id: ${++eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
try {
res.write(sseMessage);
} catch (error) {
logger.debug('Failed to write SSE event:', error);
cleanup();
}
};
sessionMonitor.on('notification', onNotification);
}
// Subscribe to events
ptyManager.on('sessionStarted', onSessionStarted);
ptyManager.on('sessionExited', onSessionExited);

View file

@ -38,6 +38,7 @@ import { HQClient } from './services/hq-client.js';
import { mdnsService } from './services/mdns-service.js';
import { PushNotificationService } from './services/push-notification-service.js';
import { RemoteRegistry } from './services/remote-registry.js';
import { SessionMonitor } from './services/session-monitor.js';
import { StreamWatcher } from './services/stream-watcher.js';
import { TerminalManager } from './services/terminal-manager.js';
import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js';
@ -458,6 +459,14 @@ export async function createApp(): Promise<AppInstance> {
const streamWatcher = new StreamWatcher(sessionManager);
logger.debug('Initialized stream watcher');
// Initialize session monitor with PTY manager
const sessionMonitor = new SessionMonitor(ptyManager);
await sessionMonitor.initialize();
// Set the session monitor on PTY manager for data tracking
ptyManager.setSessionMonitor(sessionMonitor);
logger.debug('Initialized session monitor');
// Initialize activity monitor
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
logger.debug('Initialized activity monitor');
@ -905,7 +914,7 @@ export async function createApp(): Promise<AppInstance> {
}
// Mount events router for SSE streaming
app.use('/api', createEventsRouter(ptyManager));
app.use('/api', createEventsRouter(ptyManager, sessionMonitor));
logger.debug('Mounted events routes');
// Initialize control socket

View file

@ -0,0 +1,415 @@
/**
* SessionMonitor - Server-side monitoring of terminal sessions
*
* Replaces the Mac app's polling-based SessionMonitor with real-time
* event detection directly from PTY streams. Tracks session states,
* command execution, and Claude-specific activity transitions.
*/
import { EventEmitter } from 'events';
import { ServerEventType } from '../../shared/types.js';
import type { PtyManager } from '../pty/pty-manager.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('session-monitor');
// Command tracking thresholds
const MIN_COMMAND_DURATION_MS = 3000; // Minimum duration for command completion notifications
const CLAUDE_IDLE_DEBOUNCE_MS = 2000; // Debounce period for Claude idle detection
export interface SessionState {
id: string;
name: string;
command: string[];
workingDir: string;
status: 'running' | 'exited';
isRunning: boolean;
pid?: number;
// Activity tracking
activityStatus?: {
isActive: boolean;
lastActivity?: Date;
specificStatus?: {
app: string;
status: string;
};
};
// Command tracking
commandStartTime?: Date;
lastCommand?: string;
lastExitCode?: number;
// Claude-specific tracking
isClaudeSession?: boolean;
claudeActivityState?: 'active' | 'idle' | 'unknown';
}
export interface CommandFinishedEvent {
sessionId: string;
sessionName: string;
command: string;
duration: number;
exitCode: number;
}
export interface ClaudeTurnEvent {
sessionId: string;
sessionName: string;
message?: string;
}
export class SessionMonitor extends EventEmitter {
private sessions = new Map<string, SessionState>();
private claudeIdleNotified = new Set<string>();
private lastActivityState = new Map<string, boolean>();
private commandThresholdMs = MIN_COMMAND_DURATION_MS;
private claudeIdleTimers = new Map<string, NodeJS.Timeout>();
constructor(private ptyManager: PtyManager) {
super();
this.setupEventListeners();
logger.info('SessionMonitor initialized');
}
private setupEventListeners() {
// Listen for session lifecycle events
this.ptyManager.on('sessionStarted', (sessionId: string, sessionName: string) => {
this.handleSessionStarted(sessionId, sessionName);
});
this.ptyManager.on(
'sessionExited',
(sessionId: string, sessionName: string, exitCode?: number) => {
this.handleSessionExited(sessionId, sessionName, exitCode);
}
);
// Listen for command tracking events
this.ptyManager.on('commandFinished', (data: CommandFinishedEvent) => {
this.handleCommandFinished(data);
});
// Listen for Claude activity events (if available)
this.ptyManager.on('claudeTurn', (sessionId: string, sessionName: string) => {
this.handleClaudeTurn(sessionId, sessionName);
});
}
/**
* Update session state with activity information
*/
public updateSessionActivity(sessionId: string, isActive: boolean, specificApp?: string) {
const session = this.sessions.get(sessionId);
if (!session) return;
const previousActive = session.activityStatus?.isActive ?? false;
// Update activity status
session.activityStatus = {
isActive,
lastActivity: isActive ? new Date() : session.activityStatus?.lastActivity,
specificStatus: specificApp
? {
app: specificApp,
status: isActive ? 'active' : 'idle',
}
: session.activityStatus?.specificStatus,
};
// Check if this is a Claude session
if (this.isClaudeSession(session)) {
this.trackClaudeActivity(sessionId, session, previousActive, isActive);
}
this.lastActivityState.set(sessionId, isActive);
}
/**
* Track PTY output for activity detection and bell characters
*/
public trackPtyOutput(sessionId: string, data: string) {
const session = this.sessions.get(sessionId);
if (!session) return;
// Update last activity
this.updateSessionActivity(sessionId, true);
// Detect bell character
if (data.includes('\x07')) {
this.emit('bell', {
sessionId,
sessionName: session.name,
timestamp: new Date().toISOString(),
});
}
// Detect Claude-specific patterns in output
if (this.isClaudeSession(session)) {
this.detectClaudePatterns(sessionId, session, data);
}
}
/**
* Update command information for a session
*/
public updateCommand(sessionId: string, command: string) {
const session = this.sessions.get(sessionId);
if (!session) return;
session.lastCommand = command;
session.commandStartTime = new Date();
// Mark as active when a new command starts
this.updateSessionActivity(sessionId, true);
}
/**
* Handle command completion
*/
public handleCommandCompletion(sessionId: string, exitCode: number) {
const session = this.sessions.get(sessionId);
if (!session || !session.commandStartTime || !session.lastCommand) return;
const duration = Date.now() - session.commandStartTime.getTime();
session.lastExitCode = exitCode;
// Only emit event if command ran long enough
if (duration >= this.commandThresholdMs) {
const _event: CommandFinishedEvent = {
sessionId,
sessionName: session.name,
command: session.lastCommand,
duration,
exitCode,
};
// Emit appropriate event based on exit code
if (exitCode === 0) {
this.emit('notification', {
type: ServerEventType.CommandFinished,
sessionId,
sessionName: session.name,
command: session.lastCommand,
duration,
exitCode,
});
} else {
this.emit('notification', {
type: ServerEventType.CommandError,
sessionId,
sessionName: session.name,
command: session.lastCommand,
duration,
exitCode,
});
}
}
// Clear command tracking
session.commandStartTime = undefined;
session.lastCommand = undefined;
}
private handleSessionStarted(sessionId: string, sessionName: string) {
// Get full session info from PtyManager
const ptySession = this.ptyManager.getSession(sessionId);
if (!ptySession) return;
const state: SessionState = {
id: sessionId,
name: sessionName,
command: ptySession.command || [],
workingDir: ptySession.workingDir || process.cwd(),
status: 'running',
isRunning: true,
pid: ptySession.pid,
isClaudeSession: this.detectClaudeCommand(ptySession.command || []),
};
this.sessions.set(sessionId, state);
logger.info(`Session started: ${sessionId} - ${sessionName}`);
// Emit notification event
this.emit('notification', {
type: ServerEventType.SessionStart,
sessionId,
sessionName,
timestamp: new Date().toISOString(),
});
}
private handleSessionExited(sessionId: string, sessionName: string, exitCode?: number) {
const session = this.sessions.get(sessionId);
if (!session) return;
session.status = 'exited';
session.isRunning = false;
logger.info(`Session exited: ${sessionId} - ${sessionName} (exit code: ${exitCode})`);
// Clean up Claude tracking
this.claudeIdleNotified.delete(sessionId);
this.lastActivityState.delete(sessionId);
if (this.claudeIdleTimers.has(sessionId)) {
const timer = this.claudeIdleTimers.get(sessionId);
if (timer) clearTimeout(timer);
this.claudeIdleTimers.delete(sessionId);
}
// Emit notification event
this.emit('notification', {
type: ServerEventType.SessionExit,
sessionId,
sessionName,
exitCode,
timestamp: new Date().toISOString(),
});
// Remove session after a delay to allow final events to process
setTimeout(() => {
this.sessions.delete(sessionId);
}, 5000);
}
private handleCommandFinished(data: CommandFinishedEvent) {
// Forward to our handler which will emit the appropriate notification
this.handleCommandCompletion(data.sessionId, data.exitCode);
}
private handleClaudeTurn(sessionId: string, _sessionName: string) {
const session = this.sessions.get(sessionId);
if (!session) return;
// Mark Claude as idle
this.updateSessionActivity(sessionId, false, 'claude');
}
private isClaudeSession(session: SessionState): boolean {
return session.isClaudeSession ?? false;
}
private detectClaudeCommand(command: string[]): boolean {
const commandStr = command.join(' ').toLowerCase();
return commandStr.includes('claude');
}
private trackClaudeActivity(
sessionId: string,
session: SessionState,
previousActive: boolean,
currentActive: boolean
) {
// Clear any existing idle timer
if (this.claudeIdleTimers.has(sessionId)) {
const timer = this.claudeIdleTimers.get(sessionId);
if (timer) clearTimeout(timer);
this.claudeIdleTimers.delete(sessionId);
}
// Claude went from active to potentially idle
if (previousActive && !currentActive && !this.claudeIdleNotified.has(sessionId)) {
// Set a debounce timer before declaring Claude idle
const timer = setTimeout(() => {
// Check if still idle
const currentSession = this.sessions.get(sessionId);
if (currentSession?.activityStatus && !currentSession.activityStatus.isActive) {
logger.info(`🔔 Claude turn detected for session: ${sessionId}`);
this.emit('notification', {
type: ServerEventType.ClaudeTurn,
sessionId,
sessionName: session.name,
message: 'Claude has finished responding',
timestamp: new Date().toISOString(),
});
this.claudeIdleNotified.add(sessionId);
}
this.claudeIdleTimers.delete(sessionId);
}, CLAUDE_IDLE_DEBOUNCE_MS);
this.claudeIdleTimers.set(sessionId, timer);
}
// Claude became active again - reset notification flag
if (!previousActive && currentActive) {
this.claudeIdleNotified.delete(sessionId);
}
}
private detectClaudePatterns(sessionId: string, _session: SessionState, data: string) {
// Detect patterns that indicate Claude is working or has finished
const workingPatterns = ['Thinking...', 'Analyzing', 'Working on', 'Let me'];
const idlePatterns = [
"I've completed",
"I've finished",
'Done!',
"Here's",
'The task is complete',
];
// Check for working patterns
for (const pattern of workingPatterns) {
if (data.includes(pattern)) {
this.updateSessionActivity(sessionId, true, 'claude');
return;
}
}
// Check for idle patterns
for (const pattern of idlePatterns) {
if (data.includes(pattern)) {
// Delay marking as idle to allow for follow-up output
setTimeout(() => {
this.updateSessionActivity(sessionId, false, 'claude');
}, 1000);
return;
}
}
}
/**
* Get all active sessions
*/
public getActiveSessions(): SessionState[] {
return Array.from(this.sessions.values()).filter((s) => s.isRunning);
}
/**
* Get a specific session
*/
public getSession(sessionId: string): SessionState | undefined {
return this.sessions.get(sessionId);
}
/**
* Initialize monitor with existing sessions
*/
public async initialize() {
// Get all existing sessions from PtyManager
const existingSessions = await this.ptyManager.listSessions();
for (const session of existingSessions) {
if (session.status === 'running') {
const state: SessionState = {
id: session.id,
name: session.name,
command: session.command,
workingDir: session.workingDir,
status: 'running',
isRunning: true,
pid: session.pid,
isClaudeSession: this.detectClaudeCommand(session.command),
};
this.sessions.set(session.id, state);
}
}
logger.info(`Initialized with ${this.sessions.size} existing sessions`);
}
}