Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var mainPanel: MainPanelWindow?
private var toastWindow: ToastWindow?
private var setupWindow: SetupWindow?
private var callStreamWidget: CallStreamWidget?
private var toastDismissWork: DispatchWorkItem?
private var cancellables = Set<AnyCancellable>()

Expand All @@ -33,6 +34,106 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// recording attempt — preventing the first chunk from silently failing.
requestPermissionsUpfront()

// Start embedded MCP server so any Claude Code session can call screen-grab tools.
// Configure Claude Code with: { "mcpServers": { "autoclawd": { "type": "http", "url": "http://localhost:7892/mcp" } } }
let screenGrab = ScreenGrabService()
MCPServer.shared.start(
screenGrab: screenGrab,
transcriptProvider: { [weak self] in self?.appState.liveTranscriptText ?? "" },
isPausedProvider: { [weak self] in !(self?.appState.callRoom.claudeCodeIsActive ?? true) },
canvasWriter: { [weak self] text in self?.appState.callModeSession.appendExternalMessage(text) },
onJoined: { [weak self] in self?.appState.callRoom.claudeCodeJoined() },
onLeft: { [weak self] in self?.appState.callRoom.claudeCodeLeft() },
onInviteParticipant: { [weak self] id, name, icon in
self?.appState.callRoom.connectionJoined(id: id, name: name, systemImage: icon)
},
onSetParticipantState: { [weak self] id, stateStr in
guard let room = self?.appState.callRoom else { return }
let state: ParticipantState
switch stateStr {
case "thinking": state = .thinking
case "streaming": state = .streaming
case "paused": state = .paused
default: state = .idle
}
room.setState(state, for: id)
},
onParticipantMessage: { [weak self] id, name, text in
self?.appState.callModeSession.appendParticipantMessage(id: id, name: name, text: text)
},
onRemoveParticipant: { [weak self] id in
self?.appState.callRoom.remove(id: id)
},
onHookEvent: { [weak self] event in
guard let self else { return }
// When Claude Code fires a hook, build a NarrationBundle via Llama and post
// a two-sided conversation to the call-room feed:
// 1. Claw'd tile — real tool narration (solid border)
// 2. Tool participant tile — auto-joined; optional image response
// 3. AutoClawd tile — generated reaction comment (dashed/faint border)
let room = self.appState.callRoom
let session = self.appState.callModeSession

// Ensure Claw'd is in the room (hooks can arrive before MCP initialize).
room.claudeCodeJoined()
room.setState(.thinking, for: "claude-code")

Task {
let bundle = await HookNarrationService().narrate(event)
await MainActor.run {
// Auto-join MCP tool participant (e.g. "pencil", "figma")
if let tp = bundle.toolParticipant {
room.connectionJoined(id: tp.id, name: tp.name, systemImage: tp.systemImage)
}

// Post Claw'd's real narration (solid left bar)
session.appendParticipantMessage(
id: "claude-code",
name: "Claw'd",
text: bundle.narration,
isGenerated: false
)
room.setState(event.isStop ? .idle : .streaming, for: "claude-code")

// Post tool response if it includes an image or text
if let tp = bundle.toolParticipant {
if bundle.imageData != nil || bundle.toolResponseText != nil {
room.setState(.streaming, for: tp.id)
session.appendParticipantMessage(
id: tp.id,
name: tp.name,
text: bundle.toolResponseText ?? "",
imageData: bundle.imageData,
isGenerated: false
)
room.setState(.idle, for: tp.id)
} else {
room.updateLastActivity(id: tp.id)
}
}

// Post AutoClawd's generated reaction (dashed/faint left bar)
if let reaction = bundle.autoClawdReaction {
session.appendParticipantMessage(
id: "llama",
name: "AutoClawd",
text: reaction,
isGenerated: true
)
}
}
}
}
)

// Auto-register Claude Code hooks so PostToolUse events arrive at /hook.
MCPConfigManager.writeHooksConfig()

// Configure call mode session with the same transcript provider.
appState.callModeSession.configure(
transcriptProvider: { [weak self] in self?.appState.liveTranscriptText ?? "" }
)

// Toast window disabled — logs are now shown inline inside the widget.
// AutoClawdLogger.toastPublisher
// .receive(on: DispatchQueue.main)
Expand All @@ -55,6 +156,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
.store(in: &cancellables)

// Show/hide the Call Stream Widget when pill mode changes.
appState.$pillMode
.receive(on: DispatchQueue.main)
.sink { [weak self] mode in
guard let self else { return }
if mode == .callMode && SettingsManager.shared.callStreamWidgetEnabled {
self.showCallStreamWidget()
} else {
self.callStreamWidget?.animateOut()
}
}
.store(in: &cancellables)
}

// MARK: - Call Stream Widget

private func showCallStreamWidget() {
if callStreamWidget == nil {
let widget = CallStreamWidget()
let view = CallStreamWidgetView(appState: appState) { [weak self] in
self?.appState.pillMode = .ambientIntelligence
self?.callStreamWidget?.animateOut()
}
widget.setContent(view)
callStreamWidget = widget
}
callStreamWidget?.animateIn()
}

func applicationWillTerminate(_ notification: Notification) {
Expand Down Expand Up @@ -721,6 +850,9 @@ struct PillContentView: View {
typedText: typed
)
return AnyView(v)
case .callMode:
let v = CallModeCanvasView(session: appState.callModeSession)
return AnyView(v)
}
}
}
8 changes: 8 additions & 0 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ final class AppState: ObservableObject {
@Published var sessionLifecycle: SessionLifecycleState = .undefined
@Published var sessionConfig: SessionConfig?

// Call mode state
let callModeSession = CallModeSession()
let callRoom = CallRoom()

// Code widget state
@Published var codeWidgetStep: CodeWidgetStep = .projectSelect
@Published var codeSelectedProject: Project? = nil
Expand Down Expand Up @@ -845,6 +849,10 @@ final class AppState: ObservableObject {
// Select project during review: 0 = None, 1..N = project by position
selectReviewProjectByIndex(count)
Log.info(.camera, "Gesture: review project index \(count) selected")
} else if pillMode == .callMode {
// In call mode, finger count addresses a participant
callRoom.selectByGesture(fingerCount: count)
Log.info(.camera, "Gesture: call mode participant \(count) selected")
} else if showOptionSelector {
selectOption(index: count)
Log.info(.camera, "Gesture: option \(count) selected (left fingers)")
Expand Down
Loading