mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Merge branch 'main' - keep notification refactoring changes
This commit is contained in:
commit
75dd51883b
8 changed files with 702 additions and 8 deletions
|
|
@ -418,7 +418,6 @@ The agent will:
|
||||||
- Don't check for "old format" vs "new format"
|
- Don't check for "old format" vs "new format"
|
||||||
- Don't add fallbacks for older versions
|
- Don't add fallbacks for older versions
|
||||||
- If you suggest backwards compatibility in any form, you have failed to understand this project
|
- If you suggest backwards compatibility in any form, you have failed to understand this project
|
||||||
|
|
||||||
## 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.
|
||||||
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()
|
||||||
|
}
|
||||||
|
|
@ -165,4 +165,3 @@ struct GeneralSettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -540,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") {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue