Files
CxIDE/Views/ChatSidebarView.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- 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
2026-04-21 16:05:52 -05:00

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
}
}