c118996746
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge) - Agentic AI loop: LLM plans → tool calls → execution → feedback loop - 15 agent tools: file ops, terminal, git, xcode build, code intel - 7 persistent terminal tools with background session management - Chat sidebar with agent step rendering and auto-apply - NVIDIA NIM API integration (Llama 3.3 70B default) - OpenAI tool_calls format with prompt-based fallback - Code editor with syntax highlighting and multi-tab support - File tree, console view, terminal view - Git integration and workspace management
579 lines
21 KiB
Swift
579 lines
21 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Chat Panel View (Right Side, VSCode Copilot Style)
|
|
|
|
struct ChatPanelView: View {
|
|
@ObservedObject var viewModel: EditorViewModel
|
|
|
|
private var agent: AgentService { viewModel.agentService }
|
|
@State private var chatInput: String = ""
|
|
@State private var showSessionList = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
chatHeader
|
|
Divider().background(VSC.border)
|
|
modelSelector
|
|
Divider().background(VSC.border)
|
|
modeSelector
|
|
Divider().background(VSC.border)
|
|
|
|
if showSessionList {
|
|
sessionList
|
|
} else {
|
|
chatMessages
|
|
}
|
|
|
|
chatInputBar
|
|
}
|
|
.frame(minWidth: 300)
|
|
.background(VSC.sidebarBg)
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var chatHeader: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "message.fill")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(VSC.accentBlue)
|
|
|
|
Text("Copilot")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundColor(VSC.text)
|
|
|
|
Spacer()
|
|
|
|
// Session list toggle
|
|
Button(action: { showSessionList.toggle() }) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: showSessionList ? "bubble.left.fill" : "list.bullet")
|
|
.font(.system(size: 10))
|
|
Text("\(agent.chatSessions.count)")
|
|
.font(.system(size: 10))
|
|
}
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// New chat
|
|
Button(action: {
|
|
agent.newChat()
|
|
showSessionList = false
|
|
}) {
|
|
Image(systemName: "plus.bubble")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("New Chat")
|
|
|
|
// Close panel
|
|
Button(action: { viewModel.showChatPanel = false }) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Close Chat")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// MARK: - Model Selector
|
|
|
|
private var modelSelector: some View {
|
|
VStack(spacing: 6) {
|
|
// Provider row
|
|
HStack(spacing: 4) {
|
|
ForEach(AIProvider.allCases) { provider in
|
|
providerButton(provider)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
|
|
// Model dropdown
|
|
Menu {
|
|
ForEach(agent.selectedProvider.models) { model in
|
|
Button(action: { agent.selectModel(model) }) {
|
|
HStack {
|
|
Text(model.name)
|
|
if model == agent.selectedModel {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
Spacer()
|
|
Text(model.contextLabel)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: agent.selectedProvider.iconName)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(agent.selectedProvider.color)
|
|
|
|
Text(agent.selectedModel.name)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.text)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
Text(agent.selectedModel.contextLabel)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(VSC.dimText)
|
|
|
|
if agent.selectedModel.isLocal {
|
|
Image(systemName: "desktopcomputer")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.green)
|
|
} else {
|
|
Image(systemName: "cloud")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.cyan)
|
|
}
|
|
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 5)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(4)
|
|
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VSC.inputBorder, lineWidth: 1))
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func providerButton(_ provider: AIProvider) -> some View {
|
|
Button(action: { agent.selectProvider(provider) }) {
|
|
VStack(spacing: 2) {
|
|
Image(systemName: provider.iconName)
|
|
.font(.system(size: 11))
|
|
Text(provider.rawValue)
|
|
.font(.system(size: 8))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
.foregroundColor(agent.selectedProvider == provider ? .white : VSC.dimText)
|
|
.background(agent.selectedProvider == provider ? provider.color.opacity(0.3) : Color.clear)
|
|
.cornerRadius(4)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(agent.selectedProvider == provider ? provider.color.opacity(0.6) : VSC.inputBorder, lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - Mode Selector
|
|
|
|
private var modeSelector: some View {
|
|
HStack(spacing: 4) {
|
|
ForEach(ChatMode.allCases, id: \.self) { mode in
|
|
Button(action: { agent.chatMode = mode }) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: mode.iconName)
|
|
.font(.system(size: 10))
|
|
Text(mode.rawValue)
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
.foregroundColor(agent.chatMode == mode ? .white : VSC.dimText)
|
|
.background(agent.chatMode == mode ? VSC.accentBlue.opacity(0.4) : Color.clear)
|
|
.cornerRadius(4)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(mode.description)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
// MARK: - Session List
|
|
|
|
private var sessionList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 2) {
|
|
ForEach(agent.chatSessions) { session in
|
|
sessionRow(session)
|
|
}
|
|
}
|
|
.padding(6)
|
|
}
|
|
}
|
|
|
|
private func sessionRow(_ session: ChatSession) -> some View {
|
|
let isActive = session.id == agent.activeChatID
|
|
|
|
return Button(action: {
|
|
agent.selectChat(session)
|
|
showSessionList = false
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "bubble.left")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? VSC.accentBlue : VSC.dimText)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(session.title)
|
|
.font(.system(size: 11, weight: isActive ? .semibold : .regular))
|
|
.foregroundColor(VSC.text)
|
|
.lineLimit(1)
|
|
|
|
HStack(spacing: 4) {
|
|
Text(session.model.name)
|
|
.font(.system(size: 9))
|
|
Text("·")
|
|
Text("\(session.messages.count) msgs")
|
|
.font(.system(size: 9))
|
|
}
|
|
.foregroundColor(VSC.dimText)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background(isActive ? VSC.listActive : Color.clear)
|
|
.cornerRadius(4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button("Delete") { agent.deleteChat(session) }
|
|
}
|
|
}
|
|
|
|
// MARK: - Chat Messages
|
|
|
|
private var chatMessages: some View {
|
|
Group {
|
|
if let session = agent.activeChat {
|
|
if session.messages.isEmpty {
|
|
chatEmptyState
|
|
} else {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 12) {
|
|
ForEach(session.messages) { msg in
|
|
chatMessageRow(msg)
|
|
.id(msg.id)
|
|
}
|
|
}
|
|
.padding(10)
|
|
}
|
|
.onChange(of: session.messages.count) { _, _ in
|
|
if let last = session.messages.last {
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
}
|
|
}
|
|
.onChange(of: session.messages.last?.content) { _, _ in
|
|
// Auto-scroll during streaming
|
|
if let last = session.messages.last, last.isStreaming {
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
chatEmptyState
|
|
}
|
|
}
|
|
}
|
|
|
|
private var chatEmptyState: some View {
|
|
VStack(spacing: 12) {
|
|
Spacer()
|
|
Image(systemName: "bubble.left.and.bubble.right")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(VSC.dimText.opacity(0.3))
|
|
|
|
Text("Start a conversation")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(VSC.dimText)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
quickPrompt("Explain this code")
|
|
quickPrompt("Find bugs and fix them")
|
|
quickPrompt("Create a new Swift file")
|
|
quickPrompt("Build the project")
|
|
quickPrompt("Run the tests")
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func quickPrompt(_ text: String) -> some View {
|
|
Button(action: {
|
|
chatInput = text
|
|
submitChat()
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "arrow.right.circle")
|
|
.font(.system(size: 9))
|
|
Text(text)
|
|
.font(.system(size: 11))
|
|
}
|
|
.foregroundColor(VSC.accentBlue)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(VSC.accentBlue.opacity(0.1))
|
|
.cornerRadius(12)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func chatMessageRow(_ msg: ChatMessage) -> some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
// Avatar
|
|
ZStack {
|
|
Circle()
|
|
.fill(msg.role.color.opacity(0.2))
|
|
.frame(width: 24, height: 24)
|
|
Image(systemName: msg.role.iconName)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(msg.role.color)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Role + time
|
|
HStack(spacing: 4) {
|
|
Text(msg.role.rawValue.capitalized)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(msg.role.color)
|
|
Text(msg.formattedTime)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(VSC.dimText)
|
|
|
|
if msg.isStreaming {
|
|
ProgressView()
|
|
.scaleEffect(0.5)
|
|
}
|
|
}
|
|
|
|
// Content — render agent steps and text separately
|
|
if msg.role == .assistant && hasAgentSteps(msg.content) {
|
|
agentStepsView(msg.content)
|
|
if let finalText = extractFinalText(from: msg.content), !finalText.isEmpty {
|
|
Text(finalText)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(msg.isError ? VSC.errorFg : VSC.text)
|
|
.textSelection(.enabled)
|
|
}
|
|
} else {
|
|
Text(msg.content)
|
|
.font(.system(size: 12, design: msg.role == .tool ? .monospaced : .default))
|
|
.foregroundColor(msg.isError ? VSC.errorFg : VSC.text)
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
// Streaming cursor
|
|
if msg.isStreaming {
|
|
RoundedRectangle(cornerRadius: 1)
|
|
.fill(VSC.accentBlue)
|
|
.frame(width: 6, height: 14)
|
|
.opacity(0.8)
|
|
}
|
|
|
|
// Tool results
|
|
ForEach(msg.toolResults) { result in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "wrench.fill")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(.orange)
|
|
Text(result.toolName)
|
|
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(.orange)
|
|
}
|
|
Text(result.output.prefix(500))
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(result.isError ? VSC.errorFg : VSC.dimText)
|
|
.lineLimit(10)
|
|
}
|
|
.padding(6)
|
|
.background(VSC.inputBg)
|
|
.cornerRadius(4)
|
|
}
|
|
|
|
// Applied badge or Apply button for assistant code
|
|
if msg.role == .assistant && !msg.isStreaming && msg.content.contains("```") {
|
|
if msg.autoApplied {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 9))
|
|
Text("Applied")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(.green)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Color.green.opacity(0.1))
|
|
.cornerRadius(4)
|
|
} else {
|
|
Button(action: {
|
|
let code = extractCode(from: msg.content)
|
|
viewModel.applyAgentSuggestion(code)
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "doc.on.clipboard")
|
|
.font(.system(size: 9))
|
|
Text("Apply to Editor")
|
|
.font(.system(size: 10))
|
|
}
|
|
.foregroundColor(VSC.accentBlue)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(VSC.accentBlue.opacity(0.1))
|
|
.cornerRadius(4)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(8)
|
|
.background(msg.role == .user ? VSC.listHover : Color.clear)
|
|
.cornerRadius(6)
|
|
}
|
|
|
|
private func extractCode(from text: String) -> String {
|
|
let parts = text.components(separatedBy: "```")
|
|
guard parts.count >= 3 else { return text }
|
|
let codeBlock = parts[1]
|
|
let lines = codeBlock.components(separatedBy: "\n")
|
|
if lines.count > 1 {
|
|
return lines.dropFirst().joined(separator: "\n")
|
|
}
|
|
return codeBlock
|
|
}
|
|
|
|
// MARK: - Input Bar
|
|
|
|
private var chatInputBar: some View {
|
|
VStack(spacing: 0) {
|
|
Divider().background(VSC.border)
|
|
|
|
// Status indicator
|
|
if agent.status != .idle {
|
|
HStack(spacing: 6) {
|
|
ProgressView()
|
|
.scaleEffect(0.6)
|
|
Text(agent.status.displayText)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(agent.status.color)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(VSC.inputBg)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
// Mode icon
|
|
Image(systemName: agent.chatMode.iconName)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(VSC.dimText)
|
|
|
|
TextField(placeholderForMode, text: $chatInput)
|
|
.textFieldStyle(.plain)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white)
|
|
.onSubmit { submitChat() }
|
|
|
|
// Send button
|
|
Button(action: submitChat) {
|
|
Image(systemName: "paperplane.fill")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(chatInput.trimmingCharacters(in: .whitespaces).isEmpty ? VSC.dimText : VSC.accentBlue)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(chatInput.trimmingCharacters(in: .whitespaces).isEmpty || agent.status != .idle)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(VSC.sidebarBg)
|
|
}
|
|
}
|
|
|
|
private var placeholderForMode: String {
|
|
switch agent.chatMode {
|
|
case .chat: return "Ask about your code..."
|
|
case .agent: return "Create, edit, run, build..."
|
|
case .edit: return "Describe the edit..."
|
|
}
|
|
}
|
|
|
|
private func submitChat() {
|
|
let text = chatInput.trimmingCharacters(in: .whitespaces)
|
|
guard !text.isEmpty else { return }
|
|
chatInput = ""
|
|
viewModel.sendChatMessage(text)
|
|
}
|
|
|
|
// MARK: - Agent Step Rendering
|
|
|
|
private func hasAgentSteps(_ content: String) -> Bool {
|
|
content.contains("✅ ") || content.contains("❌ ") || content.contains("🔧 ") || content.contains("⏳ ")
|
|
}
|
|
|
|
private func agentStepsView(_ content: String) -> some View {
|
|
let lines = content.components(separatedBy: "\n")
|
|
let stepLines = lines.filter { line in
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
return t.hasPrefix("✅") || t.hasPrefix("❌") || t.hasPrefix("🔧") || t.hasPrefix("⏳")
|
|
}
|
|
|
|
return VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(Array(stepLines.enumerated()), id: \.offset) { _, line in
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
HStack(spacing: 4) {
|
|
if trimmed.hasPrefix("✅") {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.green)
|
|
} else if trimmed.hasPrefix("❌") {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.red)
|
|
} else if trimmed.hasPrefix("🔧") {
|
|
Image(systemName: "wrench.and.screwdriver.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.orange)
|
|
} else {
|
|
ProgressView()
|
|
.scaleEffect(0.5)
|
|
}
|
|
Text(trimmed.dropFirst(2).trimmingCharacters(in: .whitespaces))
|
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
.foregroundColor(VSC.text)
|
|
}
|
|
}
|
|
}
|
|
.padding(8)
|
|
.background(VSC.inputBg.opacity(0.5))
|
|
.cornerRadius(6)
|
|
}
|
|
|
|
private func extractFinalText(from content: String) -> String? {
|
|
let lines = content.components(separatedBy: "\n")
|
|
let nonStepLines = lines.filter { line in
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
return !t.hasPrefix("✅") && !t.hasPrefix("❌") && !t.hasPrefix("🔧") && !t.hasPrefix("⏳")
|
|
}
|
|
let result = nonStepLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return result.isEmpty ? nil : result
|
|
}
|
|
}
|