Add macOS menu bar app with chat and settings
- Restructure project into three targets: - AppleIntelligenceCore: Shared gRPC service code - AppleIntelligenceServer: CLI server - AppleIntelligenceApp: Menu bar app - Menu bar app features: - Toggle server on/off from menu bar - Chat window with streaming AI responses - Settings: host, port, API key, auto-start, launch at login - Proper window focus handling for menu bar apps - Add build scripts for distribution: - build-app.sh: Creates signed .app bundle - create-dmg.sh: Creates distributable DMG 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var viewModel: ChatViewModel
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Messages list
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(viewModel.messages) { message in
|
||||
MessageBubble(message: message)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) { _, _ in
|
||||
if let lastMessage = viewModel.messages.last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.messages.last?.content) { _, _ in
|
||||
if let lastMessage = viewModel.messages.last {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
if let error = viewModel.errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Dismiss") {
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.1))
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Input area
|
||||
HStack(spacing: 12) {
|
||||
TextField("Message...", text: $viewModel.inputText, axis: .vertical)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1...5)
|
||||
.focused($isInputFocused)
|
||||
.onSubmit {
|
||||
if !viewModel.inputText.isEmpty {
|
||||
viewModel.sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoading {
|
||||
Button {
|
||||
viewModel.stopGeneration()
|
||||
} label: {
|
||||
Image(systemName: "stop.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
viewModel.sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(viewModel.inputText.isEmpty ? .gray : .accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.inputText.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 400, minHeight: 500)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
viewModel.clearChat()
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.help("Clear chat")
|
||||
.disabled(viewModel.messages.isEmpty)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.initialize()
|
||||
}
|
||||
.onAppear {
|
||||
// Force the app to become active and accept keyboard input
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
// Make sure the window is key
|
||||
if let window = NSApp.windows.first(where: { $0.title == "Chat" }) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
isInputFocused = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
// Return to accessory mode when chat is closed
|
||||
if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if message.role == .user {
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
|
||||
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) {
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(bubbleColor)
|
||||
.foregroundStyle(message.role == .user ? .white : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
if message.isStreaming {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
Text("Generating...")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if message.role == .assistant {
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bubbleColor: Color {
|
||||
switch message.role {
|
||||
case .user:
|
||||
return .accentColor
|
||||
case .assistant:
|
||||
return Color(nsColor: .controlBackgroundColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Server Configuration") {
|
||||
TextField("Host", text: $settings.host)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextField("Port", value: $settings.port, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("API Key (optional)", text: $settings.apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
Section("Behavior") {
|
||||
Toggle("Launch at login", isOn: $settings.launchAtLogin)
|
||||
Toggle("Auto-start server on launch", isOn: $settings.autoStartServer)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Reset to Defaults") {
|
||||
settings.resetToDefaults()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400, height: 310)
|
||||
.fixedSize()
|
||||
.onAppear {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if let window = NSApp.windows.first(where: { $0.title == "Settings" }) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user