mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
9bc3c7b891
commit
a2bd642053
20 changed files with 1777 additions and 171 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -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
|
## Key Files Quick Reference
|
||||||
|
|
||||||
- Architecture Details: `docs/ARCHITECTURE.md`
|
- Architecture Details: `docs/ARCHITECTURE.md`
|
||||||
|
|
|
||||||
501
docs/push-impl.md
Normal file
501
docs/push-impl.md
Normal 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.
|
||||||
|
|
@ -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).
|
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
|
### Notification Settings Explained
|
||||||
- **Session Start/Exit**: Get notified when a terminal session begins or ends.
|
|
||||||
- **Command Completion**: Alerts for long-running commands.
|
When you enable notifications in VibeTunnel, you can choose which events to be notified about:
|
||||||
- **Errors**: Notifications for commands that fail.
|
|
||||||
- **Terminal Bell**: Triggered by programs sending a bell character (`^G`).
|
#### 1. Session starts ✓
|
||||||
- **Claude "Your Turn"**: A special notification when Claude AI finishes a response and is awaiting your input.
|
- **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
|
## 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.
|
- **Enable**: Click the notification icon in the web UI and grant browser permission.
|
||||||
- **Technology**: Uses Service Workers and the Web Push API.
|
- **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
|
## Troubleshooting
|
||||||
|
|
||||||
- **No Notifications**: Ensure they are enabled in both VibeTunnel settings and your OS/browser settings.
|
- **No Notifications**: Ensure they are enabled in both VibeTunnel settings and your OS/browser settings.
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ final class ConfigManager {
|
||||||
var notificationClaudeTurn: Bool = false
|
var notificationClaudeTurn: Bool = false
|
||||||
var notificationSoundEnabled: Bool = true
|
var notificationSoundEnabled: Bool = true
|
||||||
var notificationVibrationEnabled: Bool = true
|
var notificationVibrationEnabled: Bool = true
|
||||||
|
var showInNotificationCenter: Bool = true
|
||||||
|
|
||||||
// Remote access
|
// Remote access
|
||||||
var ngrokEnabled: Bool = false
|
var ngrokEnabled: Bool = false
|
||||||
|
|
@ -107,6 +108,7 @@ final class ConfigManager {
|
||||||
var claudeTurn: Bool
|
var claudeTurn: Bool
|
||||||
var soundEnabled: Bool
|
var soundEnabled: Bool
|
||||||
var vibrationEnabled: Bool
|
var vibrationEnabled: Bool
|
||||||
|
var showInNotificationCenter: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct RemoteAccessConfig: Codable {
|
private struct RemoteAccessConfig: Codable {
|
||||||
|
|
@ -193,6 +195,9 @@ final class ConfigManager {
|
||||||
self.notificationClaudeTurn = notif.claudeTurn
|
self.notificationClaudeTurn = notif.claudeTurn
|
||||||
self.notificationSoundEnabled = notif.soundEnabled
|
self.notificationSoundEnabled = notif.soundEnabled
|
||||||
self.notificationVibrationEnabled = notif.vibrationEnabled
|
self.notificationVibrationEnabled = notif.vibrationEnabled
|
||||||
|
if let showInCenter = notif.showInNotificationCenter {
|
||||||
|
self.showInNotificationCenter = showInCenter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,6 +241,7 @@ final class ConfigManager {
|
||||||
self.notificationClaudeTurn = false
|
self.notificationClaudeTurn = false
|
||||||
self.notificationSoundEnabled = true
|
self.notificationSoundEnabled = true
|
||||||
self.notificationVibrationEnabled = true
|
self.notificationVibrationEnabled = true
|
||||||
|
self.showInNotificationCenter = true
|
||||||
|
|
||||||
saveConfiguration()
|
saveConfiguration()
|
||||||
}
|
}
|
||||||
|
|
@ -281,7 +287,8 @@ final class ConfigManager {
|
||||||
bell: notificationBell,
|
bell: notificationBell,
|
||||||
claudeTurn: notificationClaudeTurn,
|
claudeTurn: notificationClaudeTurn,
|
||||||
soundEnabled: notificationSoundEnabled,
|
soundEnabled: notificationSoundEnabled,
|
||||||
vibrationEnabled: notificationVibrationEnabled
|
vibrationEnabled: notificationVibrationEnabled,
|
||||||
|
showInNotificationCenter: showInNotificationCenter
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Observation
|
||||||
import os.log
|
import os.log
|
||||||
@preconcurrency import UserNotifications
|
@preconcurrency import UserNotifications
|
||||||
|
|
||||||
|
|
@ -8,6 +9,7 @@ import os.log
|
||||||
/// Connects to the VibeTunnel server to receive real-time events like session starts,
|
/// Connects to the VibeTunnel server to receive real-time events like session starts,
|
||||||
/// command completions, and errors, then displays them as native macOS notifications.
|
/// command completions, and errors, then displays them as native macOS notifications.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@Observable
|
||||||
final class NotificationService: NSObject {
|
final class NotificationService: NSObject {
|
||||||
@MainActor
|
@MainActor
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
@ -730,9 +732,7 @@ final class NotificationService: NSObject {
|
||||||
deinit {
|
deinit {
|
||||||
// Note: We can't call disconnect() here because it's @MainActor isolated
|
// Note: We can't call disconnect() here because it's @MainActor isolated
|
||||||
// The cleanup will happen when the EventSource is deallocated
|
// The cleanup will happen when the EventSource is deallocated
|
||||||
eventSource?.disconnect()
|
// NotificationCenter observers are automatically removed on deinit in modern Swift
|
||||||
eventSource = nil
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
mac/VibeTunnel/Presentation/Components/HelpTooltip.swift
Normal file
54
mac/VibeTunnel/Presentation/Components/HelpTooltip.swift
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,6 @@ import SwiftUI
|
||||||
struct GeneralSettingsView: View {
|
struct GeneralSettingsView: View {
|
||||||
@AppStorage("autostart")
|
@AppStorage("autostart")
|
||||||
private var autostart = false
|
private var autostart = false
|
||||||
@AppStorage("showNotifications")
|
|
||||||
private var showNotifications = true
|
|
||||||
@AppStorage(AppConstants.UserDefaultsKeys.updateChannel)
|
@AppStorage(AppConstants.UserDefaultsKeys.updateChannel)
|
||||||
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
||||||
@AppStorage(AppConstants.UserDefaultsKeys.showInDock)
|
@AppStorage(AppConstants.UserDefaultsKeys.showInDock)
|
||||||
|
|
@ -16,8 +14,10 @@ struct GeneralSettingsView: View {
|
||||||
private var preventSleepWhenRunning = true
|
private var preventSleepWhenRunning = true
|
||||||
|
|
||||||
@Environment(ConfigManager.self) private var configManager
|
@Environment(ConfigManager.self) private var configManager
|
||||||
|
@Environment(SystemPermissionManager.self) private var permissionManager
|
||||||
|
|
||||||
@State private var isCheckingForUpdates = false
|
@State private var isCheckingForUpdates = false
|
||||||
|
@State private var permissionUpdateTrigger = 0
|
||||||
|
|
||||||
private let startupManager = StartupManager()
|
private let startupManager = StartupManager()
|
||||||
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings")
|
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings")
|
||||||
|
|
@ -26,10 +26,20 @@ struct GeneralSettingsView: View {
|
||||||
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
|
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNotificationPreferences() {
|
// MARK: - Helper Properties
|
||||||
// Load current preferences from ConfigManager and notify the service
|
|
||||||
let prefs = NotificationService.NotificationPreferences(fromConfig: configManager)
|
// IMPORTANT: These computed properties ensure the UI always shows current permission state.
|
||||||
NotificationService.shared.updatePreferences(prefs)
|
// 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 {
|
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
|
// Prevent Sleep
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
|
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
|
||||||
|
|
@ -185,6 +87,13 @@ struct GeneralSettingsView: View {
|
||||||
Text("Application")
|
Text("Application")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System Permissions section (moved from Security)
|
||||||
|
PermissionsSection(
|
||||||
|
hasAppleScriptPermission: hasAppleScriptPermission,
|
||||||
|
hasAccessibilityPermission: hasAccessibilityPermission,
|
||||||
|
permissionManager: permissionManager
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
|
|
@ -193,6 +102,19 @@ struct GeneralSettingsView: View {
|
||||||
.task {
|
.task {
|
||||||
// Sync launch at login status
|
// Sync launch at login status
|
||||||
autostart = startupManager.isLaunchAtLoginEnabled
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,10 @@ struct RemoteAccessSettingsView: View {
|
||||||
private var serverPort = "4020"
|
private var serverPort = "4020"
|
||||||
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
|
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
|
||||||
private var accessModeString = AppConstants.Defaults.dashboardAccessMode
|
private var accessModeString = AppConstants.Defaults.dashboardAccessMode
|
||||||
|
@AppStorage(AppConstants.UserDefaultsKeys.authenticationMode)
|
||||||
|
private var authModeString = "os"
|
||||||
|
|
||||||
|
@State private var authMode: AuthenticationMode = .osAuth
|
||||||
|
|
||||||
@Environment(NgrokService.self)
|
@Environment(NgrokService.self)
|
||||||
private var ngrokService
|
private var ngrokService
|
||||||
|
|
@ -43,6 +47,14 @@ struct RemoteAccessSettingsView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
// Authentication section (moved from Security)
|
||||||
|
AuthenticationSection(
|
||||||
|
authMode: $authMode,
|
||||||
|
enableSSHKeys: .constant(authMode == .sshKeys || authMode == .both),
|
||||||
|
logger: logger,
|
||||||
|
serverManager: serverManager
|
||||||
|
)
|
||||||
|
|
||||||
TailscaleIntegrationSection(
|
TailscaleIntegrationSection(
|
||||||
tailscaleService: tailscaleService,
|
tailscaleService: tailscaleService,
|
||||||
serverPort: serverPort,
|
serverPort: serverPort,
|
||||||
|
|
@ -78,6 +90,9 @@ struct RemoteAccessSettingsView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
onAppearSetup()
|
onAppearSetup()
|
||||||
updateLocalIPAddress()
|
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) {
|
.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
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview("Remote Access Settings") {
|
#Preview("Remote Access Settings") {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import Foundation
|
||||||
/// with associated display names and SF Symbol icons for the tab bar.
|
/// with associated display names and SF Symbol icons for the tab bar.
|
||||||
enum SettingsTab: String, CaseIterable {
|
enum SettingsTab: String, CaseIterable {
|
||||||
case general
|
case general
|
||||||
|
case notifications
|
||||||
case quickStart
|
case quickStart
|
||||||
case dashboard
|
case dashboard
|
||||||
case remoteAccess
|
case remoteAccess
|
||||||
case securityPermissions
|
|
||||||
case advanced
|
case advanced
|
||||||
case debug
|
case debug
|
||||||
case about
|
case about
|
||||||
|
|
@ -17,10 +17,10 @@ enum SettingsTab: String, CaseIterable {
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "General"
|
case .general: "General"
|
||||||
|
case .notifications: "Notifications"
|
||||||
case .quickStart: "Quick Start"
|
case .quickStart: "Quick Start"
|
||||||
case .dashboard: "Dashboard"
|
case .dashboard: "Dashboard"
|
||||||
case .remoteAccess: "Remote"
|
case .remoteAccess: "Remote"
|
||||||
case .securityPermissions: "Security"
|
|
||||||
case .advanced: "Advanced"
|
case .advanced: "Advanced"
|
||||||
case .debug: "Debug"
|
case .debug: "Debug"
|
||||||
case .about: "About"
|
case .about: "About"
|
||||||
|
|
@ -30,10 +30,10 @@ enum SettingsTab: String, CaseIterable {
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "gear"
|
case .general: "gear"
|
||||||
|
case .notifications: "bell.badge"
|
||||||
case .quickStart: "bolt.fill"
|
case .quickStart: "bolt.fill"
|
||||||
case .dashboard: "server.rack"
|
case .dashboard: "server.rack"
|
||||||
case .remoteAccess: "network"
|
case .remoteAccess: "network"
|
||||||
case .securityPermissions: "lock.shield"
|
|
||||||
case .advanced: "gearshape.2"
|
case .advanced: "gearshape.2"
|
||||||
case .debug: "hammer"
|
case .debug: "hammer"
|
||||||
case .about: "info.circle"
|
case .about: "info.circle"
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,17 @@ struct SettingsView: View {
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
static let defaultTabSize = CGSize(width: 520, height: 710)
|
static let defaultTabSize = CGSize(width: 550, height: 710)
|
||||||
static let fallbackTabSize = CGSize(width: 520, height: 450)
|
static let fallbackTabSize = CGSize(width: 550, height: 450)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Define ideal sizes for each tab
|
/// Define ideal sizes for each tab
|
||||||
private let tabSizes: [SettingsTab: CGSize] = [
|
private let tabSizes: [SettingsTab: CGSize] = [
|
||||||
.general: Layout.defaultTabSize,
|
.general: Layout.defaultTabSize,
|
||||||
|
.notifications: Layout.defaultTabSize,
|
||||||
.quickStart: Layout.defaultTabSize,
|
.quickStart: Layout.defaultTabSize,
|
||||||
.dashboard: Layout.defaultTabSize,
|
.dashboard: Layout.defaultTabSize,
|
||||||
.remoteAccess: Layout.defaultTabSize,
|
.remoteAccess: Layout.defaultTabSize,
|
||||||
.securityPermissions: Layout.defaultTabSize,
|
|
||||||
.advanced: Layout.defaultTabSize,
|
.advanced: Layout.defaultTabSize,
|
||||||
.debug: Layout.defaultTabSize,
|
.debug: Layout.defaultTabSize,
|
||||||
.about: Layout.defaultTabSize
|
.about: Layout.defaultTabSize
|
||||||
|
|
@ -38,6 +38,12 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.tag(SettingsTab.general)
|
.tag(SettingsTab.general)
|
||||||
|
|
||||||
|
NotificationSettingsView()
|
||||||
|
.tabItem {
|
||||||
|
Label(SettingsTab.notifications.displayName, systemImage: SettingsTab.notifications.icon)
|
||||||
|
}
|
||||||
|
.tag(SettingsTab.notifications)
|
||||||
|
|
||||||
QuickStartSettingsView()
|
QuickStartSettingsView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon)
|
Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon)
|
||||||
|
|
@ -56,15 +62,6 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.tag(SettingsTab.remoteAccess)
|
.tag(SettingsTab.remoteAccess)
|
||||||
|
|
||||||
SecurityPermissionsSettingsView()
|
|
||||||
.tabItem {
|
|
||||||
Label(
|
|
||||||
SettingsTab.securityPermissions.displayName,
|
|
||||||
systemImage: SettingsTab.securityPermissions.icon
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.tag(SettingsTab.securityPermissions)
|
|
||||||
|
|
||||||
AdvancedSettingsView()
|
AdvancedSettingsView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)
|
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ struct VibeTunnelApp: App {
|
||||||
@State var sessionService: SessionService?
|
@State var sessionService: SessionService?
|
||||||
@State var worktreeService = WorktreeService(serverManager: ServerManager.shared)
|
@State var worktreeService = WorktreeService(serverManager: ServerManager.shared)
|
||||||
@State var configManager = ConfigManager.shared
|
@State var configManager = ConfigManager.shared
|
||||||
|
@State var notificationService = NotificationService.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Connect the app delegate to this app instance
|
// Connect the app delegate to this app instance
|
||||||
|
|
@ -57,6 +58,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(repositoryDiscoveryService)
|
.environment(repositoryDiscoveryService)
|
||||||
.environment(configManager)
|
.environment(configManager)
|
||||||
.environment(worktreeService)
|
.environment(worktreeService)
|
||||||
|
.environment(notificationService)
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
.defaultSize(width: 580, height: 480)
|
.defaultSize(width: 580, height: 480)
|
||||||
|
|
@ -83,6 +85,7 @@ struct VibeTunnelApp: App {
|
||||||
sessionMonitor: sessionMonitor
|
sessionMonitor: sessionMonitor
|
||||||
))
|
))
|
||||||
.environment(worktreeService)
|
.environment(worktreeService)
|
||||||
|
.environment(notificationService)
|
||||||
} else {
|
} else {
|
||||||
Text("Session not found")
|
Text("Session not found")
|
||||||
.frame(width: 400, height: 300)
|
.frame(width: 400, height: 300)
|
||||||
|
|
@ -109,6 +112,7 @@ struct VibeTunnelApp: App {
|
||||||
sessionMonitor: sessionMonitor
|
sessionMonitor: sessionMonitor
|
||||||
))
|
))
|
||||||
.environment(worktreeService)
|
.environment(worktreeService)
|
||||||
|
.environment(notificationService)
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(after: .appInfo) {
|
CommandGroup(after: .appInfo) {
|
||||||
|
|
|
||||||
|
|
@ -322,23 +322,6 @@ export class SessionHeader extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
|
<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 (always visible) -->
|
||||||
<keyboard-capture-indicator
|
<keyboard-capture-indicator
|
||||||
.active=${this.keyboardCaptureActive}
|
.active=${this.keyboardCaptureActive}
|
||||||
|
|
@ -385,6 +368,24 @@ export class SessionHeader extends LitElement {
|
||||||
: html`
|
: html`
|
||||||
<!-- Individual buttons for larger screens -->
|
<!-- Individual buttons for larger screens -->
|
||||||
<div class="flex items-center gap-2">
|
<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 -->
|
<!-- Status dropdown -->
|
||||||
<session-status-dropdown
|
<session-status-dropdown
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
|
|
|
||||||
|
|
@ -484,11 +484,12 @@ export class Settings extends LitElement {
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-muted mb-3">Notification Types</h4>
|
<h4 class="text-sm font-medium text-muted mb-3">Notification Types</h4>
|
||||||
<div class="space-y-2 bg-base rounded-lg p-3">
|
<div class="space-y-2 bg-base rounded-lg p-3">
|
||||||
${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates')}
|
${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates or crashes (shows exit code)')}
|
||||||
${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts')}
|
${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts (useful for shared terminals)')}
|
||||||
${this.renderNotificationToggle('commandError', 'Session Errors', 'When errors occur in sessions')}
|
${this.renderNotificationToggle('commandError', 'Session Errors', 'When commands fail with non-zero exit codes')}
|
||||||
${this.renderNotificationToggle('commandCompletion', 'Command Completion', 'When long-running commands finish')}
|
${this.renderNotificationToggle('commandCompletion', 'Command Completion', 'When commands taking >3 seconds finish (builds, tests, etc.)')}
|
||||||
${this.renderNotificationToggle('bell', 'System Alerts', 'Important system notifications')}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -496,8 +497,8 @@ export class Settings extends LitElement {
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-muted mb-3">Notification Behavior</h4>
|
<h4 class="text-sm font-medium text-muted mb-3">Notification Behavior</h4>
|
||||||
<div class="space-y-2 bg-base rounded-lg p-3">
|
<div class="space-y-2 bg-base rounded-lg p-3">
|
||||||
${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play sound with notifications')}
|
${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play a notification sound when alerts are triggered')}
|
||||||
${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications')}
|
${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications (mobile devices only)')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,10 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
/* Smooth transition when keyboard appears/disappears */
|
/* Smooth transition when keyboard appears/disappears */
|
||||||
transition: bottom 0.3s ease-out;
|
transition: bottom 0.3s ease-out;
|
||||||
background-color: rgb(var(--color-bg-secondary));
|
background-color: rgb(var(--color-bg-secondary));
|
||||||
|
/* Prevent horizontal overflow */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The actual bar with buttons */
|
/* The actual bar with buttons */
|
||||||
|
|
@ -299,6 +303,9 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
/* Add shadow for visibility */
|
/* Add shadow for visibility */
|
||||||
box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5);
|
box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5);
|
||||||
|
/* Ensure proper width */
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Quick key buttons */
|
/* Quick key buttons */
|
||||||
|
|
@ -448,7 +455,7 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
>
|
>
|
||||||
<div class="quick-keys-bar">
|
<div class="quick-keys-bar">
|
||||||
<!-- Row 1 -->
|
<!-- 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(
|
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map(
|
||||||
({ key, label, modifier, arrow, toggle }) => html`
|
({ key, label, modifier, arrow, toggle }) => html`
|
||||||
<button
|
<button
|
||||||
|
|
@ -500,7 +507,7 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
this.showCtrlKeys
|
this.showCtrlKeys
|
||||||
? html`
|
? html`
|
||||||
<!-- Ctrl shortcuts row -->
|
<!-- 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(
|
${CTRL_SHORTCUTS.map(
|
||||||
({ key, label, combo, special }) => html`
|
({ key, label, combo, special }) => html`
|
||||||
<button
|
<button
|
||||||
|
|
@ -535,7 +542,7 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
: this.showFunctionKeys
|
: this.showFunctionKeys
|
||||||
? html`
|
? html`
|
||||||
<!-- Function keys row -->
|
<!-- 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(
|
${FUNCTION_KEYS.map(
|
||||||
({ key, label }) => html`
|
({ key, label }) => html`
|
||||||
<button
|
<button
|
||||||
|
|
@ -569,7 +576,7 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<!-- Regular row 2 -->
|
<!-- 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(
|
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
|
||||||
({ key, label, modifier, combo, special, toggle }) => html`
|
({ key, label, modifier, combo, special, toggle }) => html`
|
||||||
<button
|
<button
|
||||||
|
|
@ -608,7 +615,7 @@ export class TerminalQuickKeys extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Row 3 - Additional special characters (always visible) -->
|
<!-- 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(
|
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
|
||||||
({ key, label, modifier, combo, special }) => html`
|
({ key, label, modifier, combo, special }) => html`
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1489,7 +1489,10 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
|
||||||
|
|
||||||
/* Position keyboard button */
|
/* Position keyboard button */
|
||||||
.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 */
|
/* Ensure button is always touchable */
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
} from '../../shared/types.js';
|
} from '../../shared/types.js';
|
||||||
import { TitleMode } from '../../shared/types.js';
|
import { TitleMode } from '../../shared/types.js';
|
||||||
import { ProcessTreeAnalyzer } from '../services/process-tree-analyzer.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 { ActivityDetector, type ActivityState } from '../utils/activity-detector.js';
|
||||||
import { TitleSequenceFilter } from '../utils/ansi-title-filter.js';
|
import { TitleSequenceFilter } from '../utils/ansi-title-filter.js';
|
||||||
import { createLogger } from '../utils/logger.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 processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification
|
||||||
private activityFileWarningsLogged = new Set<string>(); // Track which sessions we've logged warnings for
|
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 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
|
// Command tracking for notifications
|
||||||
private commandTracking = new Map<
|
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
|
* Setup terminal resize detection for when the hosting terminal is resized
|
||||||
*/
|
*/
|
||||||
|
|
@ -708,6 +717,11 @@ export class PtyManager extends EventEmitter {
|
||||||
ptyProcess.onData((data: string) => {
|
ptyProcess.onData((data: string) => {
|
||||||
let processedData = data;
|
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
|
// If title mode is not NONE, filter out any title sequences the process might
|
||||||
// have written to the stream.
|
// have written to the stream.
|
||||||
if (session.titleMode !== undefined && session.titleMode !== TitleMode.NONE) {
|
if (session.titleMode !== undefined && session.titleMode !== TitleMode.NONE) {
|
||||||
|
|
@ -723,6 +737,16 @@ export class PtyManager extends EventEmitter {
|
||||||
if (activity.specificStatus?.status !== session.lastActivityStatus) {
|
if (activity.specificStatus?.status !== session.lastActivityStatus) {
|
||||||
session.lastActivityStatus = activity.specificStatus?.status;
|
session.lastActivityStatus = activity.specificStatus?.status;
|
||||||
this.markTitleUpdateNeeded(session);
|
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.currentCommand = commandProc.command;
|
||||||
session.commandStartTime = Date.now();
|
session.commandStartTime = Date.now();
|
||||||
|
|
||||||
|
// Update SessionMonitor with new command
|
||||||
|
if (this.sessionMonitor) {
|
||||||
|
this.sessionMonitor.updateCommand(session.id, commandProc.command);
|
||||||
|
}
|
||||||
|
|
||||||
// Special logging for Claude commands
|
// Special logging for Claude commands
|
||||||
const isClaudeCommand = commandProc.command.toLowerCase().includes('claude');
|
const isClaudeCommand = commandProc.command.toLowerCase().includes('claude');
|
||||||
if (isClaudeCommand) {
|
if (isClaudeCommand) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
|
||||||
import { type Request, type Response, Router } from 'express';
|
import { type Request, type Response, Router } from 'express';
|
||||||
import { type ServerEvent, ServerEventType } from '../../shared/types.js';
|
import { type ServerEvent, ServerEventType } from '../../shared/types.js';
|
||||||
import type { PtyManager } from '../pty/pty-manager.js';
|
import type { PtyManager } from '../pty/pty-manager.js';
|
||||||
|
import type { SessionMonitor } from '../services/session-monitor.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
|
|
||||||
const logger = createLogger('events');
|
const logger = createLogger('events');
|
||||||
|
|
@ -12,7 +13,10 @@ export const serverEventBus = new EventEmitter();
|
||||||
/**
|
/**
|
||||||
* Server-Sent Events (SSE) endpoint for real-time event streaming
|
* 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();
|
const router = Router();
|
||||||
|
|
||||||
// SSE endpoint for event streaming
|
// SSE endpoint for event streaming
|
||||||
|
|
@ -47,6 +51,7 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
||||||
let onCommandFinished: (data: CommandFinishedEvent) => void;
|
let onCommandFinished: (data: CommandFinishedEvent) => void;
|
||||||
// biome-ignore lint/style/useConst: These are assigned later in the code
|
// biome-ignore lint/style/useConst: These are assigned later in the code
|
||||||
let onClaudeTurn: (sessionId: string, sessionName: string) => void;
|
let onClaudeTurn: (sessionId: string, sessionName: string) => void;
|
||||||
|
let onNotification: (event: ServerEvent) => void;
|
||||||
|
|
||||||
// Cleanup function to remove event listeners
|
// Cleanup function to remove event listeners
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
|
@ -57,6 +62,9 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
||||||
ptyManager.off('sessionExited', onSessionExited);
|
ptyManager.off('sessionExited', onSessionExited);
|
||||||
ptyManager.off('commandFinished', onCommandFinished);
|
ptyManager.off('commandFinished', onCommandFinished);
|
||||||
ptyManager.off('claudeTurn', onClaudeTurn);
|
ptyManager.off('claudeTurn', onClaudeTurn);
|
||||||
|
if (sessionMonitor) {
|
||||||
|
sessionMonitor.off('notification', onNotification);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send initial connection event
|
// 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
|
// Subscribe to events
|
||||||
ptyManager.on('sessionStarted', onSessionStarted);
|
ptyManager.on('sessionStarted', onSessionStarted);
|
||||||
ptyManager.on('sessionExited', onSessionExited);
|
ptyManager.on('sessionExited', onSessionExited);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { HQClient } from './services/hq-client.js';
|
||||||
import { mdnsService } from './services/mdns-service.js';
|
import { mdnsService } from './services/mdns-service.js';
|
||||||
import { PushNotificationService } from './services/push-notification-service.js';
|
import { PushNotificationService } from './services/push-notification-service.js';
|
||||||
import { RemoteRegistry } from './services/remote-registry.js';
|
import { RemoteRegistry } from './services/remote-registry.js';
|
||||||
|
import { SessionMonitor } from './services/session-monitor.js';
|
||||||
import { StreamWatcher } from './services/stream-watcher.js';
|
import { StreamWatcher } from './services/stream-watcher.js';
|
||||||
import { TerminalManager } from './services/terminal-manager.js';
|
import { TerminalManager } from './services/terminal-manager.js';
|
||||||
import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.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);
|
const streamWatcher = new StreamWatcher(sessionManager);
|
||||||
logger.debug('Initialized stream watcher');
|
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
|
// Initialize activity monitor
|
||||||
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
|
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
|
||||||
logger.debug('Initialized activity monitor');
|
logger.debug('Initialized activity monitor');
|
||||||
|
|
@ -905,7 +914,7 @@ export async function createApp(): Promise<AppInstance> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount events router for SSE streaming
|
// Mount events router for SSE streaming
|
||||||
app.use('/api', createEventsRouter(ptyManager));
|
app.use('/api', createEventsRouter(ptyManager, sessionMonitor));
|
||||||
logger.debug('Mounted events routes');
|
logger.debug('Mounted events routes');
|
||||||
|
|
||||||
// Initialize control socket
|
// Initialize control socket
|
||||||
|
|
|
||||||
415
web/src/server/services/session-monitor.ts
Normal file
415
web/src/server/services/session-monitor.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue