mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +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
|
||||
|
||||
- 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).
|
||||
|
||||
### Key Monitored Events
|
||||
- **Session Start/Exit**: Get notified when a terminal session begins or ends.
|
||||
- **Command Completion**: Alerts for long-running commands.
|
||||
- **Errors**: Notifications for commands that fail.
|
||||
- **Terminal Bell**: Triggered by programs sending a bell character (`^G`).
|
||||
- **Claude "Your Turn"**: A special notification when Claude AI finishes a response and is awaiting your input.
|
||||
### Notification Settings Explained
|
||||
|
||||
When you enable notifications in VibeTunnel, you can choose which events to be notified about:
|
||||
|
||||
#### 1. Session starts ✓
|
||||
- **Notification**: "Session Started" with the session name
|
||||
- **Triggers when**: A new terminal session is created
|
||||
- **Use case**: Know when someone starts using your shared terminal
|
||||
|
||||
#### 2. Session ends ✓
|
||||
- **Notification**: "Session Ended" with the session name
|
||||
- **Shows exit code**: If the session crashed or exited abnormally
|
||||
- **Triggers when**: A terminal session closes
|
||||
- **Use case**: Monitor when sessions terminate, especially if unexpected
|
||||
|
||||
#### 3. Commands complete (> 3 seconds) ✓
|
||||
- **Notification**: "Your Turn" with the command that finished
|
||||
- **Shows duration**: How long the command took to complete
|
||||
- **Triggers when**: Any command that runs for more than 3 seconds finishes
|
||||
- **Use case**: Get notified when long builds, tests, or scripts complete
|
||||
|
||||
#### 4. Commands fail ✓
|
||||
- **Notification**: "Command Failed" with the failed command
|
||||
- **Shows exit code**: The specific error code returned
|
||||
- **Triggers when**: Any command returns a non-zero exit code
|
||||
- **Use case**: Immediately know when something goes wrong
|
||||
|
||||
#### 5. Terminal bell (\u{0007}) ✓
|
||||
- **Notification**: "Terminal Bell" with the session name
|
||||
- **Triggers when**: A program outputs the bell character (ASCII 7/^G)
|
||||
- **Common sources**: vim alerts, IRC mentions, completion sounds
|
||||
- **Use case**: Get alerts from terminal programs that use the bell
|
||||
|
||||
#### 6. Claude turn notifications ✓
|
||||
- **Notification**: "Your Turn" when Claude finishes responding
|
||||
- **Smart detection**: Monitors Claude CLI sessions automatically
|
||||
- **Triggers when**: Claude transitions from typing to waiting for input
|
||||
- **How it works**:
|
||||
- Detects sessions running Claude (by command or app name)
|
||||
- Tracks when Claude stops outputting text
|
||||
- Only notifies once per response (won't spam)
|
||||
- **Use case**: Know when Claude has finished its response and needs your input
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
The notification system in VibeTunnel follows a layered architecture:
|
||||
|
||||
```
|
||||
Terminal Events → Session Monitor → Event Processing → Notification Service → OS/Browser
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
#### 1. Session Monitor (`SessionMonitor.swift`)
|
||||
- **Role**: Tracks all terminal sessions and their state changes
|
||||
- **Key responsibilities**:
|
||||
- Monitors session lifecycle (start/exit)
|
||||
- Tracks command execution and duration
|
||||
- Detects Claude CLI activity transitions
|
||||
- Filters events based on thresholds (e.g., 3-second rule)
|
||||
|
||||
#### 2. Server Event System (`ServerEvent.swift`)
|
||||
- **Event types**:
|
||||
- `sessionStart`, `sessionExit`: Session lifecycle
|
||||
- `commandFinished`, `commandError`: Command execution
|
||||
- `bell`: Terminal bell character detection
|
||||
- `claudeTurn`: Claude AI idle detection
|
||||
- **Event data**: Each event carries session ID, display name, duration, exit codes, etc.
|
||||
|
||||
#### 3. Notification Service (`NotificationService.swift`)
|
||||
- **macOS integration**: Uses `UserNotifications` framework
|
||||
- **Event source**: Connects to server via SSE at `/api/events`
|
||||
- **Preference management**: Checks user settings before sending
|
||||
- **Permission handling**: Manages notification authorization
|
||||
|
||||
#### 4. Configuration Manager (`ConfigManager.swift`)
|
||||
- **Settings storage**: Persists user notification preferences
|
||||
- **Default values**: All notification types enabled by default
|
||||
- **Real-time updates**: Changes take effect immediately
|
||||
|
||||
### Event Flow
|
||||
|
||||
1. **Terminal Activity**: User runs commands, sessions start/stop
|
||||
2. **Event Detection**: Session Monitor detects changes
|
||||
3. **Event Creation**: Creates typed `ServerEvent` objects
|
||||
4. **Filtering**: Checks user preferences and thresholds
|
||||
5. **Notification Dispatch**: Sends to OS notification center
|
||||
6. **User Interaction**: Shows native macOS notifications
|
||||
|
||||
### Special Features
|
||||
|
||||
#### Claude Detection Algorithm
|
||||
```swift
|
||||
// Detects Claude sessions by command or app name
|
||||
let isClaudeSession = session.command.contains("claude") ||
|
||||
session.app.lowercased().contains("claude")
|
||||
|
||||
// Tracks activity state transitions
|
||||
if previousActive && !currentActive && !alreadyNotified {
|
||||
// Claude went from typing to idle - send notification
|
||||
}
|
||||
```
|
||||
|
||||
#### Command Duration Tracking
|
||||
- Only notifies for commands > 3 seconds
|
||||
- Tracks start time when command begins
|
||||
- Calculates duration on completion
|
||||
- Formats duration for display (e.g., "5 seconds", "2 minutes")
|
||||
|
||||
#### Bell Character Detection
|
||||
- Terminal emulator detects ASCII 7 (`\u{0007}`)
|
||||
- Forwards bell events through WebSocket
|
||||
- Server converts to notification event
|
||||
|
||||
## Native macOS Notifications
|
||||
|
||||
|
|
@ -27,6 +136,38 @@ For non-macOS clients or remote access, VibeTunnel supports web push notificatio
|
|||
- **Enable**: Click the notification icon in the web UI and grant browser permission.
|
||||
- **Technology**: Uses Service Workers and the Web Push API.
|
||||
|
||||
### HTTPS Requirement
|
||||
|
||||
⚠️ **Important**: Web push notifications require HTTPS to function. This is a security requirement enforced by all modern browsers.
|
||||
|
||||
- **Local development**: Works on `http://localhost:4020` without HTTPS
|
||||
- **Remote access**: Requires HTTPS with a valid SSL certificate
|
||||
- **Why**: Service Workers (which power push notifications) only work on secure origins to prevent man-in-the-middle attacks
|
||||
|
||||
### Enabling HTTPS for Remote Access
|
||||
|
||||
If you need web push notifications when accessing VibeTunnel remotely, you'll need to serve it over HTTPS. Here are some solutions:
|
||||
|
||||
#### Tailscale Serve (Recommended)
|
||||
[Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve) is an excellent solution for automatically creating HTTPS connections within your network:
|
||||
|
||||
```bash
|
||||
# Install Tailscale and connect to your network
|
||||
# Then expose VibeTunnel with HTTPS:
|
||||
tailscale serve https / http://localhost:4020
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Automatic HTTPS with valid certificates
|
||||
- Works within your Tailscale network
|
||||
- No port forwarding or firewall configuration needed
|
||||
- Push notifications will work for all devices on your Tailnet
|
||||
|
||||
#### Other Options
|
||||
- **Ngrok**: Provides HTTPS tunnels but requires external exposure
|
||||
- **Cloudflare Tunnel**: Free HTTPS tunneling service
|
||||
- **Let's Encrypt**: For permanent HTTPS setup with your own domain
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No Notifications**: Ensure they are enabled in both VibeTunnel settings and your OS/browser settings.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ final class ConfigManager {
|
|||
var notificationClaudeTurn: Bool = false
|
||||
var notificationSoundEnabled: Bool = true
|
||||
var notificationVibrationEnabled: Bool = true
|
||||
var showInNotificationCenter: Bool = true
|
||||
|
||||
// Remote access
|
||||
var ngrokEnabled: Bool = false
|
||||
|
|
@ -107,6 +108,7 @@ final class ConfigManager {
|
|||
var claudeTurn: Bool
|
||||
var soundEnabled: Bool
|
||||
var vibrationEnabled: Bool
|
||||
var showInNotificationCenter: Bool?
|
||||
}
|
||||
|
||||
private struct RemoteAccessConfig: Codable {
|
||||
|
|
@ -193,6 +195,9 @@ final class ConfigManager {
|
|||
self.notificationClaudeTurn = notif.claudeTurn
|
||||
self.notificationSoundEnabled = notif.soundEnabled
|
||||
self.notificationVibrationEnabled = notif.vibrationEnabled
|
||||
if let showInCenter = notif.showInNotificationCenter {
|
||||
self.showInNotificationCenter = showInCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +241,7 @@ final class ConfigManager {
|
|||
self.notificationClaudeTurn = false
|
||||
self.notificationSoundEnabled = true
|
||||
self.notificationVibrationEnabled = true
|
||||
self.showInNotificationCenter = true
|
||||
|
||||
saveConfiguration()
|
||||
}
|
||||
|
|
@ -281,7 +287,8 @@ final class ConfigManager {
|
|||
bell: notificationBell,
|
||||
claudeTurn: notificationClaudeTurn,
|
||||
soundEnabled: notificationSoundEnabled,
|
||||
vibrationEnabled: notificationVibrationEnabled
|
||||
vibrationEnabled: notificationVibrationEnabled,
|
||||
showInNotificationCenter: showInNotificationCenter
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import os.log
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ import os.log
|
|||
/// Connects to the VibeTunnel server to receive real-time events like session starts,
|
||||
/// command completions, and errors, then displays them as native macOS notifications.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NotificationService: NSObject {
|
||||
@MainActor
|
||||
static let shared = NotificationService()
|
||||
|
|
@ -730,9 +732,7 @@ final class NotificationService: NSObject {
|
|||
deinit {
|
||||
// Note: We can't call disconnect() here because it's @MainActor isolated
|
||||
// The cleanup will happen when the EventSource is deallocated
|
||||
eventSource?.disconnect()
|
||||
eventSource = nil
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
// NotificationCenter observers are automatically removed on deinit in modern Swift
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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 {
|
||||
@AppStorage("autostart")
|
||||
private var autostart = false
|
||||
@AppStorage("showNotifications")
|
||||
private var showNotifications = true
|
||||
@AppStorage(AppConstants.UserDefaultsKeys.updateChannel)
|
||||
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
||||
@AppStorage(AppConstants.UserDefaultsKeys.showInDock)
|
||||
|
|
@ -16,8 +14,10 @@ struct GeneralSettingsView: View {
|
|||
private var preventSleepWhenRunning = true
|
||||
|
||||
@Environment(ConfigManager.self) private var configManager
|
||||
@Environment(SystemPermissionManager.self) private var permissionManager
|
||||
|
||||
@State private var isCheckingForUpdates = false
|
||||
@State private var permissionUpdateTrigger = 0
|
||||
|
||||
private let startupManager = StartupManager()
|
||||
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "GeneralSettings")
|
||||
|
|
@ -26,10 +26,20 @@ struct GeneralSettingsView: View {
|
|||
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
|
||||
}
|
||||
|
||||
private func updateNotificationPreferences() {
|
||||
// Load current preferences from ConfigManager and notify the service
|
||||
let prefs = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||
NotificationService.shared.updatePreferences(prefs)
|
||||
// MARK: - Helper Properties
|
||||
|
||||
// IMPORTANT: These computed properties ensure the UI always shows current permission state.
|
||||
// The permissionUpdateTrigger dependency forces SwiftUI to re-evaluate these properties
|
||||
// when permissions change. Without this, the UI would not update when permissions are
|
||||
// granted in System Settings while this view is visible.
|
||||
private var hasAppleScriptPermission: Bool {
|
||||
_ = permissionUpdateTrigger
|
||||
return permissionManager.hasPermission(.appleScript)
|
||||
}
|
||||
|
||||
private var hasAccessibilityPermission: Bool {
|
||||
_ = permissionUpdateTrigger
|
||||
return permissionManager.hasPermission(.accessibility)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -66,114 +76,6 @@ struct GeneralSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Show Session Notifications
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Show Session Notifications", isOn: $showNotifications)
|
||||
.onChange(of: showNotifications) { _, newValue in
|
||||
// Ensure NotificationService starts/stops based on the toggle
|
||||
if newValue {
|
||||
Task {
|
||||
// Request permissions and show test notification
|
||||
let granted = await NotificationService.shared
|
||||
.requestPermissionAndShowTestNotification()
|
||||
|
||||
if granted {
|
||||
await NotificationService.shared.start()
|
||||
} else {
|
||||
// If permission denied, turn toggle back off
|
||||
await MainActor.run {
|
||||
showNotifications = false
|
||||
|
||||
// Show alert explaining the situation
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Notification Permission Required"
|
||||
alert.informativeText = "VibeTunnel needs permission to show notifications. Please enable notifications for VibeTunnel in System Settings."
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Open System Settings")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
// Settings will already be open from the service
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NotificationService.shared.stop()
|
||||
}
|
||||
}
|
||||
Text("Display native macOS notifications for session and command events.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if showNotifications {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Notify me for:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 20)
|
||||
.padding(.top, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Session starts", isOn: Binding(
|
||||
get: { configManager.notificationSessionStart },
|
||||
set: { newValue in
|
||||
configManager.notificationSessionStart = newValue
|
||||
updateNotificationPreferences()
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Toggle("Session ends", isOn: Binding(
|
||||
get: { configManager.notificationSessionExit },
|
||||
set: { newValue in
|
||||
configManager.notificationSessionExit = newValue
|
||||
updateNotificationPreferences()
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Toggle("Commands complete (> 3 seconds)", isOn: Binding(
|
||||
get: { configManager.notificationCommandCompletion },
|
||||
set: { newValue in
|
||||
configManager.notificationCommandCompletion = newValue
|
||||
updateNotificationPreferences()
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Toggle("Commands fail", isOn: Binding(
|
||||
get: { configManager.notificationCommandError },
|
||||
set: { newValue in
|
||||
configManager.notificationCommandError = newValue
|
||||
updateNotificationPreferences()
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Toggle("Terminal bell (\u{0007})", isOn: Binding(
|
||||
get: { configManager.notificationBell },
|
||||
set: { newValue in
|
||||
configManager.notificationBell = newValue
|
||||
updateNotificationPreferences()
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Toggle("Claude turn notifications", isOn: Binding(
|
||||
get: { configManager.notificationClaudeTurn },
|
||||
set: { newValue in
|
||||
configManager.notificationClaudeTurn = newValue
|
||||
updateNotificationPreferences()
|
||||
}
|
||||
))
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent Sleep
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
|
||||
|
|
@ -185,6 +87,13 @@ struct GeneralSettingsView: View {
|
|||
Text("Application")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
// System Permissions section (moved from Security)
|
||||
PermissionsSection(
|
||||
hasAppleScriptPermission: hasAppleScriptPermission,
|
||||
hasAccessibilityPermission: hasAccessibilityPermission,
|
||||
permissionManager: permissionManager
|
||||
)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
|
|
@ -193,6 +102,19 @@ struct GeneralSettingsView: View {
|
|||
.task {
|
||||
// Sync launch at login status
|
||||
autostart = startupManager.isLaunchAtLoginEnabled
|
||||
// Check permissions before first render to avoid UI flashing
|
||||
await permissionManager.checkAllPermissions()
|
||||
}
|
||||
.onAppear {
|
||||
// Register for continuous monitoring
|
||||
permissionManager.registerForMonitoring()
|
||||
}
|
||||
.onDisappear {
|
||||
permissionManager.unregisterFromMonitoring()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .permissionsUpdated)) { _ in
|
||||
// Increment trigger to force computed property re-evaluation
|
||||
permissionUpdateTrigger += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,3 +165,115 @@ struct GeneralSettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permissions Section
|
||||
|
||||
private struct PermissionsSection: View {
|
||||
let hasAppleScriptPermission: Bool
|
||||
let hasAccessibilityPermission: Bool
|
||||
let permissionManager: SystemPermissionManager
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
// Automation permission
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Terminal Automation")
|
||||
.font(.body)
|
||||
Text("Required to launch and control terminal applications.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if hasAppleScriptPermission {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Granted")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 2)
|
||||
.frame(height: 22) // Match small button height
|
||||
.contextMenu {
|
||||
Button("Refresh Status") {
|
||||
permissionManager.forcePermissionRecheck()
|
||||
}
|
||||
Button("Open System Settings...") {
|
||||
permissionManager.requestPermission(.appleScript)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
permissionManager.requestPermission(.appleScript)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
// Accessibility permission
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Accessibility")
|
||||
.font(.body)
|
||||
Text("Required to enter terminal startup commands.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if hasAccessibilityPermission {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Granted")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 2)
|
||||
.frame(height: 22) // Match small button height
|
||||
.contextMenu {
|
||||
Button("Refresh Status") {
|
||||
permissionManager.forcePermissionRecheck()
|
||||
}
|
||||
Button("Open System Settings...") {
|
||||
permissionManager.requestPermission(.accessibility)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
permissionManager.requestPermission(.accessibility)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("System Permissions")
|
||||
.font(.headline)
|
||||
} footer: {
|
||||
if hasAppleScriptPermission && hasAccessibilityPermission {
|
||||
Text(
|
||||
"All permissions granted. VibeTunnel has full functionality."
|
||||
)
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.green)
|
||||
} else {
|
||||
Text(
|
||||
"Terminals can be captured without permissions, however new sessions won't load."
|
||||
)
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
|
||||
private var accessModeString = AppConstants.Defaults.dashboardAccessMode
|
||||
@AppStorage(AppConstants.UserDefaultsKeys.authenticationMode)
|
||||
private var authModeString = "os"
|
||||
|
||||
@State private var authMode: AuthenticationMode = .osAuth
|
||||
|
||||
@Environment(NgrokService.self)
|
||||
private var ngrokService
|
||||
|
|
@ -43,6 +47,14 @@ struct RemoteAccessSettingsView: View {
|
|||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Authentication section (moved from Security)
|
||||
AuthenticationSection(
|
||||
authMode: $authMode,
|
||||
enableSSHKeys: .constant(authMode == .sshKeys || authMode == .both),
|
||||
logger: logger,
|
||||
serverManager: serverManager
|
||||
)
|
||||
|
||||
TailscaleIntegrationSection(
|
||||
tailscaleService: tailscaleService,
|
||||
serverPort: serverPort,
|
||||
|
|
@ -78,6 +90,9 @@ struct RemoteAccessSettingsView: View {
|
|||
.onAppear {
|
||||
onAppearSetup()
|
||||
updateLocalIPAddress()
|
||||
// Initialize authentication mode from stored value
|
||||
let storedMode = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.authenticationMode) ?? "os"
|
||||
authMode = AuthenticationMode(rawValue: storedMode) ?? .osAuth
|
||||
}
|
||||
}
|
||||
.alert("ngrok Authentication Required", isPresented: $showingAuthTokenAlert) {
|
||||
|
|
@ -525,6 +540,108 @@ private struct ErrorView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Authentication Section
|
||||
|
||||
private struct AuthenticationSection: View {
|
||||
@Binding var authMode: AuthenticationMode
|
||||
@Binding var enableSSHKeys: Bool
|
||||
let logger: Logger
|
||||
let serverManager: ServerManager
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Authentication mode picker
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Authentication Method")
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Picker("", selection: $authMode) {
|
||||
ForEach(AuthenticationMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName)
|
||||
.tag(mode)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.frame(alignment: .trailing)
|
||||
.onChange(of: authMode) { _, newValue in
|
||||
// Save the authentication mode
|
||||
UserDefaults.standard.set(
|
||||
newValue.rawValue,
|
||||
forKey: AppConstants.UserDefaultsKeys.authenticationMode
|
||||
)
|
||||
|
||||
Task {
|
||||
logger.info("Authentication mode changed to: \(newValue.rawValue)")
|
||||
await serverManager.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(authMode.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// Additional info based on selected mode
|
||||
if authMode == .osAuth || authMode == .both {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.blue)
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 16, height: 16)
|
||||
Text("Uses your macOS username: \(NSUserName())")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if authMode == .sshKeys || authMode == .both {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 16, height: 16)
|
||||
Text("SSH keys from ~/.ssh/authorized_keys")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Open folder") {
|
||||
let sshPath = NSHomeDirectory() + "/.ssh"
|
||||
if FileManager.default.fileExists(atPath: sshPath) {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: sshPath))
|
||||
} else {
|
||||
// Create .ssh directory if it doesn't exist
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: sshPath,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: sshPath))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Authentication")
|
||||
.font(.headline)
|
||||
} footer: {
|
||||
Text("Localhost connections are always accessible without authentication.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Remote Access Settings") {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import Foundation
|
|||
/// with associated display names and SF Symbol icons for the tab bar.
|
||||
enum SettingsTab: String, CaseIterable {
|
||||
case general
|
||||
case notifications
|
||||
case quickStart
|
||||
case dashboard
|
||||
case remoteAccess
|
||||
case securityPermissions
|
||||
case advanced
|
||||
case debug
|
||||
case about
|
||||
|
|
@ -17,10 +17,10 @@ enum SettingsTab: String, CaseIterable {
|
|||
var displayName: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .notifications: "Notifications"
|
||||
case .quickStart: "Quick Start"
|
||||
case .dashboard: "Dashboard"
|
||||
case .remoteAccess: "Remote"
|
||||
case .securityPermissions: "Security"
|
||||
case .advanced: "Advanced"
|
||||
case .debug: "Debug"
|
||||
case .about: "About"
|
||||
|
|
@ -30,10 +30,10 @@ enum SettingsTab: String, CaseIterable {
|
|||
var icon: String {
|
||||
switch self {
|
||||
case .general: "gear"
|
||||
case .notifications: "bell.badge"
|
||||
case .quickStart: "bolt.fill"
|
||||
case .dashboard: "server.rack"
|
||||
case .remoteAccess: "network"
|
||||
case .securityPermissions: "lock.shield"
|
||||
case .advanced: "gearshape.2"
|
||||
case .debug: "hammer"
|
||||
case .about: "info.circle"
|
||||
|
|
|
|||
|
|
@ -14,17 +14,17 @@ struct SettingsView: View {
|
|||
// MARK: - Constants
|
||||
|
||||
private enum Layout {
|
||||
static let defaultTabSize = CGSize(width: 520, height: 710)
|
||||
static let fallbackTabSize = CGSize(width: 520, height: 450)
|
||||
static let defaultTabSize = CGSize(width: 550, height: 710)
|
||||
static let fallbackTabSize = CGSize(width: 550, height: 450)
|
||||
}
|
||||
|
||||
/// Define ideal sizes for each tab
|
||||
private let tabSizes: [SettingsTab: CGSize] = [
|
||||
.general: Layout.defaultTabSize,
|
||||
.notifications: Layout.defaultTabSize,
|
||||
.quickStart: Layout.defaultTabSize,
|
||||
.dashboard: Layout.defaultTabSize,
|
||||
.remoteAccess: Layout.defaultTabSize,
|
||||
.securityPermissions: Layout.defaultTabSize,
|
||||
.advanced: Layout.defaultTabSize,
|
||||
.debug: Layout.defaultTabSize,
|
||||
.about: Layout.defaultTabSize
|
||||
|
|
@ -38,6 +38,12 @@ struct SettingsView: View {
|
|||
}
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
NotificationSettingsView()
|
||||
.tabItem {
|
||||
Label(SettingsTab.notifications.displayName, systemImage: SettingsTab.notifications.icon)
|
||||
}
|
||||
.tag(SettingsTab.notifications)
|
||||
|
||||
QuickStartSettingsView()
|
||||
.tabItem {
|
||||
Label(SettingsTab.quickStart.displayName, systemImage: SettingsTab.quickStart.icon)
|
||||
|
|
@ -56,15 +62,6 @@ struct SettingsView: View {
|
|||
}
|
||||
.tag(SettingsTab.remoteAccess)
|
||||
|
||||
SecurityPermissionsSettingsView()
|
||||
.tabItem {
|
||||
Label(
|
||||
SettingsTab.securityPermissions.displayName,
|
||||
systemImage: SettingsTab.securityPermissions.icon
|
||||
)
|
||||
}
|
||||
.tag(SettingsTab.securityPermissions)
|
||||
|
||||
AdvancedSettingsView()
|
||||
.tabItem {
|
||||
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ struct VibeTunnelApp: App {
|
|||
@State var sessionService: SessionService?
|
||||
@State var worktreeService = WorktreeService(serverManager: ServerManager.shared)
|
||||
@State var configManager = ConfigManager.shared
|
||||
@State var notificationService = NotificationService.shared
|
||||
|
||||
init() {
|
||||
// Connect the app delegate to this app instance
|
||||
|
|
@ -57,6 +58,7 @@ struct VibeTunnelApp: App {
|
|||
.environment(repositoryDiscoveryService)
|
||||
.environment(configManager)
|
||||
.environment(worktreeService)
|
||||
.environment(notificationService)
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.defaultSize(width: 580, height: 480)
|
||||
|
|
@ -83,6 +85,7 @@ struct VibeTunnelApp: App {
|
|||
sessionMonitor: sessionMonitor
|
||||
))
|
||||
.environment(worktreeService)
|
||||
.environment(notificationService)
|
||||
} else {
|
||||
Text("Session not found")
|
||||
.frame(width: 400, height: 300)
|
||||
|
|
@ -109,6 +112,7 @@ struct VibeTunnelApp: App {
|
|||
sessionMonitor: sessionMonitor
|
||||
))
|
||||
.environment(worktreeService)
|
||||
.environment(notificationService)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
|
|
|
|||
|
|
@ -322,23 +322,6 @@ export class SessionHeader extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
|
||||
<!-- Git worktree toggle button (visible when session has Git repo) -->
|
||||
${
|
||||
this.hasGitRepo
|
||||
? html`
|
||||
<button
|
||||
class="bg-bg-tertiary border border-border rounded-md p-2 text-primary transition-all duration-200 hover:bg-surface-hover hover:border-primary flex-shrink-0"
|
||||
@click=${() => this.onToggleViewMode?.()}
|
||||
title="${this.viewMode === 'terminal' ? 'Show Worktrees' : 'Show Terminal'}"
|
||||
data-testid="worktree-toggle-button"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
|
||||
</svg>
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
<!-- Keyboard capture indicator (always visible) -->
|
||||
<keyboard-capture-indicator
|
||||
.active=${this.keyboardCaptureActive}
|
||||
|
|
@ -385,6 +368,24 @@ export class SessionHeader extends LitElement {
|
|||
: html`
|
||||
<!-- Individual buttons for larger screens -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Git worktree toggle button (visible when session has Git repo) -->
|
||||
${
|
||||
this.hasGitRepo
|
||||
? html`
|
||||
<button
|
||||
class="bg-bg-tertiary border border-border rounded-md p-2 text-primary transition-all duration-200 hover:bg-surface-hover hover:border-primary flex-shrink-0"
|
||||
@click=${() => this.onToggleViewMode?.()}
|
||||
title="${this.viewMode === 'terminal' ? 'Show Worktrees' : 'Show Terminal'}"
|
||||
data-testid="worktree-toggle-button"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
|
||||
</svg>
|
||||
</button>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<!-- Status dropdown -->
|
||||
<session-status-dropdown
|
||||
.session=${this.session}
|
||||
|
|
|
|||
|
|
@ -484,11 +484,12 @@ export class Settings extends LitElement {
|
|||
<div>
|
||||
<h4 class="text-sm font-medium text-muted mb-3">Notification Types</h4>
|
||||
<div class="space-y-2 bg-base rounded-lg p-3">
|
||||
${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates')}
|
||||
${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts')}
|
||||
${this.renderNotificationToggle('commandError', 'Session Errors', 'When errors occur in sessions')}
|
||||
${this.renderNotificationToggle('commandCompletion', 'Command Completion', 'When long-running commands finish')}
|
||||
${this.renderNotificationToggle('bell', 'System Alerts', 'Important system notifications')}
|
||||
${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates or crashes (shows exit code)')}
|
||||
${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts (useful for shared terminals)')}
|
||||
${this.renderNotificationToggle('commandError', 'Session Errors', 'When commands fail with non-zero exit codes')}
|
||||
${this.renderNotificationToggle('commandCompletion', 'Command Completion', 'When commands taking >3 seconds finish (builds, tests, etc.)')}
|
||||
${this.renderNotificationToggle('bell', 'System Alerts', 'Terminal bell (^G) from vim, IRC mentions, completion sounds')}
|
||||
${this.renderNotificationToggle('claudeTurn', 'Claude Turn', 'When Claude AI finishes responding and awaits input')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -496,8 +497,8 @@ export class Settings extends LitElement {
|
|||
<div>
|
||||
<h4 class="text-sm font-medium text-muted mb-3">Notification Behavior</h4>
|
||||
<div class="space-y-2 bg-base rounded-lg p-3">
|
||||
${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play sound with notifications')}
|
||||
${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications')}
|
||||
${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play a notification sound when alerts are triggered')}
|
||||
${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications (mobile devices only)')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -287,6 +287,10 @@ export class TerminalQuickKeys extends LitElement {
|
|||
/* Smooth transition when keyboard appears/disappears */
|
||||
transition: bottom 0.3s ease-out;
|
||||
background-color: rgb(var(--color-bg-secondary));
|
||||
/* Prevent horizontal overflow */
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* The actual bar with buttons */
|
||||
|
|
@ -299,6 +303,9 @@ export class TerminalQuickKeys extends LitElement {
|
|||
appearance: none;
|
||||
/* Add shadow for visibility */
|
||||
box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5);
|
||||
/* Ensure proper width */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Quick key buttons */
|
||||
|
|
@ -448,7 +455,7 @@ export class TerminalQuickKeys extends LitElement {
|
|||
>
|
||||
<div class="quick-keys-bar">
|
||||
<!-- Row 1 -->
|
||||
<div class="flex gap-0.5 justify-center mb-0.5">
|
||||
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
|
||||
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map(
|
||||
({ key, label, modifier, arrow, toggle }) => html`
|
||||
<button
|
||||
|
|
@ -500,7 +507,7 @@ export class TerminalQuickKeys extends LitElement {
|
|||
this.showCtrlKeys
|
||||
? html`
|
||||
<!-- Ctrl shortcuts row -->
|
||||
<div class="flex gap-0.5 justify-between flex-wrap mb-0.5">
|
||||
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
|
||||
${CTRL_SHORTCUTS.map(
|
||||
({ key, label, combo, special }) => html`
|
||||
<button
|
||||
|
|
@ -535,7 +542,7 @@ export class TerminalQuickKeys extends LitElement {
|
|||
: this.showFunctionKeys
|
||||
? html`
|
||||
<!-- Function keys row -->
|
||||
<div class="flex gap-0.5 justify-between mb-0.5">
|
||||
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
|
||||
${FUNCTION_KEYS.map(
|
||||
({ key, label }) => html`
|
||||
<button
|
||||
|
|
@ -569,7 +576,7 @@ export class TerminalQuickKeys extends LitElement {
|
|||
`
|
||||
: html`
|
||||
<!-- Regular row 2 -->
|
||||
<div class="flex gap-0.5 justify-center mb-0.5">
|
||||
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
|
||||
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
|
||||
({ key, label, modifier, combo, special, toggle }) => html`
|
||||
<button
|
||||
|
|
@ -608,7 +615,7 @@ export class TerminalQuickKeys extends LitElement {
|
|||
}
|
||||
|
||||
<!-- Row 3 - Additional special characters (always visible) -->
|
||||
<div class="flex gap-0.5 justify-center">
|
||||
<div class="flex gap-0.5 justify-center flex-wrap">
|
||||
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
|
||||
({ key, label, modifier, combo, special }) => html`
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1489,7 +1489,10 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
|
|||
|
||||
/* Position keyboard button */
|
||||
.keyboard-button {
|
||||
@apply fixed bottom-3 right-3 z-50;
|
||||
@apply fixed z-50;
|
||||
/* Account for safe areas on mobile devices */
|
||||
bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
right: calc(0.75rem + env(safe-area-inset-right, 0px));
|
||||
/* Ensure button is always touchable */
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
} from '../../shared/types.js';
|
||||
import { TitleMode } from '../../shared/types.js';
|
||||
import { ProcessTreeAnalyzer } from '../services/process-tree-analyzer.js';
|
||||
import type { SessionMonitor } from '../services/session-monitor.js';
|
||||
import { ActivityDetector, type ActivityState } from '../utils/activity-detector.js';
|
||||
import { TitleSequenceFilter } from '../utils/ansi-title-filter.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
|
@ -137,6 +138,7 @@ export class PtyManager extends EventEmitter {
|
|||
private processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification
|
||||
private activityFileWarningsLogged = new Set<string>(); // Track which sessions we've logged warnings for
|
||||
private lastWrittenActivityState = new Map<string, string>(); // Track last written activity state to avoid unnecessary writes
|
||||
private sessionMonitor: SessionMonitor | null = null; // Reference to SessionMonitor for notification tracking
|
||||
|
||||
// Command tracking for notifications
|
||||
private commandTracking = new Map<
|
||||
|
|
@ -181,6 +183,13 @@ export class PtyManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the SessionMonitor instance for activity tracking
|
||||
*/
|
||||
public setSessionMonitor(monitor: SessionMonitor): void {
|
||||
this.sessionMonitor = monitor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup terminal resize detection for when the hosting terminal is resized
|
||||
*/
|
||||
|
|
@ -708,6 +717,11 @@ export class PtyManager extends EventEmitter {
|
|||
ptyProcess.onData((data: string) => {
|
||||
let processedData = data;
|
||||
|
||||
// Track PTY output in SessionMonitor for activity and bell detection
|
||||
if (this.sessionMonitor) {
|
||||
this.sessionMonitor.trackPtyOutput(session.id, data);
|
||||
}
|
||||
|
||||
// If title mode is not NONE, filter out any title sequences the process might
|
||||
// have written to the stream.
|
||||
if (session.titleMode !== undefined && session.titleMode !== TitleMode.NONE) {
|
||||
|
|
@ -723,6 +737,16 @@ export class PtyManager extends EventEmitter {
|
|||
if (activity.specificStatus?.status !== session.lastActivityStatus) {
|
||||
session.lastActivityStatus = activity.specificStatus?.status;
|
||||
this.markTitleUpdateNeeded(session);
|
||||
|
||||
// Update SessionMonitor with activity change
|
||||
if (this.sessionMonitor) {
|
||||
const isActive = activity.specificStatus?.status === 'working';
|
||||
this.sessionMonitor.updateSessionActivity(
|
||||
session.id,
|
||||
isActive,
|
||||
activity.specificStatus?.app
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2456,6 +2480,11 @@ export class PtyManager extends EventEmitter {
|
|||
session.currentCommand = commandProc.command;
|
||||
session.commandStartTime = Date.now();
|
||||
|
||||
// Update SessionMonitor with new command
|
||||
if (this.sessionMonitor) {
|
||||
this.sessionMonitor.updateCommand(session.id, commandProc.command);
|
||||
}
|
||||
|
||||
// Special logging for Claude commands
|
||||
const isClaudeCommand = commandProc.command.toLowerCase().includes('claude');
|
||||
if (isClaudeCommand) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
|
|||
import { type Request, type Response, Router } from 'express';
|
||||
import { type ServerEvent, ServerEventType } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import type { SessionMonitor } from '../services/session-monitor.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('events');
|
||||
|
|
@ -12,7 +13,10 @@ export const serverEventBus = new EventEmitter();
|
|||
/**
|
||||
* Server-Sent Events (SSE) endpoint for real-time event streaming
|
||||
*/
|
||||
export function createEventsRouter(ptyManager: PtyManager): Router {
|
||||
export function createEventsRouter(
|
||||
ptyManager: PtyManager,
|
||||
sessionMonitor?: SessionMonitor
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// SSE endpoint for event streaming
|
||||
|
|
@ -47,6 +51,7 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
let onCommandFinished: (data: CommandFinishedEvent) => void;
|
||||
// biome-ignore lint/style/useConst: These are assigned later in the code
|
||||
let onClaudeTurn: (sessionId: string, sessionName: string) => void;
|
||||
let onNotification: (event: ServerEvent) => void;
|
||||
|
||||
// Cleanup function to remove event listeners
|
||||
const cleanup = () => {
|
||||
|
|
@ -57,6 +62,9 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
ptyManager.off('sessionExited', onSessionExited);
|
||||
ptyManager.off('commandFinished', onCommandFinished);
|
||||
ptyManager.off('claudeTurn', onClaudeTurn);
|
||||
if (sessionMonitor) {
|
||||
sessionMonitor.off('notification', onNotification);
|
||||
}
|
||||
};
|
||||
|
||||
// Send initial connection event
|
||||
|
|
@ -166,6 +174,26 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
});
|
||||
};
|
||||
|
||||
// Handle SessionMonitor notification events
|
||||
if (sessionMonitor) {
|
||||
onNotification = (event: ServerEvent) => {
|
||||
// SessionMonitor already provides properly formatted ServerEvent objects
|
||||
logger.info(`📢 SessionMonitor notification: ${event.type} for session ${event.sessionId}`);
|
||||
|
||||
// Proper SSE format with id, event, and data fields
|
||||
const sseMessage = `id: ${++eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
|
||||
try {
|
||||
res.write(sseMessage);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to write SSE event:', error);
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
sessionMonitor.on('notification', onNotification);
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
ptyManager.on('sessionStarted', onSessionStarted);
|
||||
ptyManager.on('sessionExited', onSessionExited);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { HQClient } from './services/hq-client.js';
|
|||
import { mdnsService } from './services/mdns-service.js';
|
||||
import { PushNotificationService } from './services/push-notification-service.js';
|
||||
import { RemoteRegistry } from './services/remote-registry.js';
|
||||
import { SessionMonitor } from './services/session-monitor.js';
|
||||
import { StreamWatcher } from './services/stream-watcher.js';
|
||||
import { TerminalManager } from './services/terminal-manager.js';
|
||||
import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js';
|
||||
|
|
@ -458,6 +459,14 @@ export async function createApp(): Promise<AppInstance> {
|
|||
const streamWatcher = new StreamWatcher(sessionManager);
|
||||
logger.debug('Initialized stream watcher');
|
||||
|
||||
// Initialize session monitor with PTY manager
|
||||
const sessionMonitor = new SessionMonitor(ptyManager);
|
||||
await sessionMonitor.initialize();
|
||||
|
||||
// Set the session monitor on PTY manager for data tracking
|
||||
ptyManager.setSessionMonitor(sessionMonitor);
|
||||
logger.debug('Initialized session monitor');
|
||||
|
||||
// Initialize activity monitor
|
||||
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
|
||||
logger.debug('Initialized activity monitor');
|
||||
|
|
@ -905,7 +914,7 @@ export async function createApp(): Promise<AppInstance> {
|
|||
}
|
||||
|
||||
// Mount events router for SSE streaming
|
||||
app.use('/api', createEventsRouter(ptyManager));
|
||||
app.use('/api', createEventsRouter(ptyManager, sessionMonitor));
|
||||
logger.debug('Mounted events routes');
|
||||
|
||||
// Initialize control socket
|
||||
|
|
|
|||
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