spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// TaskDetailView.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The command-center detail for one task: the 52pt header (editable title,9// centered model chip, safety-mode segmented control, workspace chip,10// export, info popover), the conversation column + collapsible plan panel,11// the collapsible Activity/Terminal drawer, and the floating input bar.12//1314import SwiftUI15import UniformTypeIdentifiers1617/// Thin wrapper resolving the task's RunController from the RunHub.18struct TaskDetailView: View {19 let taskID: AgentTask.ID20 @EnvironmentObject private var hub: RunHub2122 var body: some View {23 TaskDetailContent(controller: hub.controller(for: taskID))24 .id(taskID)25 }26}2728struct TaskDetailContent: View {29 @ObservedObject var controller: RunController3031 @EnvironmentObject private var store: TaskStore32 @EnvironmentObject private var catalog: ModelCatalog33 @EnvironmentObject private var vault: KeyVaultStore34 @EnvironmentObject private var appearance: AppearanceStore35 @EnvironmentObject private var personas: PersonaStore36 @EnvironmentObject private var uiState: AppUIState3738 @State private var draft = ""39 @State private var editingTitle = false40 @State private var titleDraft = ""41 @State private var showingInfo = false42 @State private var planVisible = true43 @State private var drawerVisible = false4445 private var task: AgentTask? { store.task(id: controller.taskID) }4647 private var currentModel: AIModel? {48 guard let task else { return nil }49 return catalog.model(id: task.modelID, provider: task.providerID) ?? catalog.defaultAgentModel50 }5152 var body: some View {53 VStack(spacing: 0) {54 header55 ZyquoHairline()56 HStack(spacing: 0) {57 VStack(spacing: 0) {58 if let task, task.messages.isEmpty, controller.entries.isEmpty {59 emptyState(task)60 } else if let task {61 ConversationView(task: task, controller: controller)62 }63 if noKeyForModel {64 noKeyBanner65 }66 InputBarView(67 text: $draft,68 isRunning: controller.isRunning,69 disabledReason: inputDisabledReason,70 onRun: run,71 onStop: { controller.cancel() }72 )73 }74 .frame(maxWidth: .infinity)75 if planVisible {76 Rectangle()77 .fill(ZyquoColor.border)78 .frame(width: ZyquoMetrics.hairline)79 PlanPanelView(controller: controller)80 .transition(.move(edge: .trailing).combined(with: .opacity))81 }82 }83 if drawerVisible {84 ZyquoHairline()85 TerminalDrawerView(controller: controller)86 .transition(.move(edge: .bottom).combined(with: .opacity))87 }88 }89 .background(ZyquoColor.background)90 .background(91 // ⌘. stops the current run.92 Button("") { controller.cancel() }93 .keyboardShortcut(".", modifiers: .command)94 .hidden()95 )96 .onAppear {97 if let pending = store.pendingDraft {98 draft = pending99 store.pendingDraft = nil100 }101 }102 .onChange(of: controller.isRunning) { running in103 if running { drawerVisible = true }104 }105 // App-level command signals (⌘⇧A, palette actions).106 .onChange(of: uiState.auditLogRequest) { _ in107 withAnimation(ZyquoMotion.appear) { drawerVisible = true }108 }109 .onChange(of: uiState.drawerToggleRequest) { _ in110 withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() }111 }112 .onChange(of: uiState.planToggleRequest) { _ in113 withAnimation(ZyquoMotion.appear) { planVisible.toggle() }114 }115 }116117 // MARK: - Header (52pt)118119 private var header: some View {120 ZStack {121 ModelChipView(model: currentModel, onSelect: select(model:))122 HStack(spacing: ZyquoSpacing.xs) {123 titleView124 Spacer()125 headerControls126 }127 }128 .padding(.horizontal, ZyquoMetrics.contentInset)129 .frame(height: ZyquoMetrics.headerHeight)130 }131132 @ViewBuilder133 private var titleView: some View {134 if editingTitle {135 TextField("Title", text: $titleDraft, onCommit: {136 store.rename(controller.taskID, to: titleDraft)137 editingTitle = false138 })139 .textFieldStyle(.plain)140 .font(ZyquoFont.bodyEmphasis(size: 13))141 .frame(maxWidth: 220)142 } else {143 Text(task?.title ?? "")144 .font(ZyquoFont.bodyEmphasis(size: 13))145 .foregroundStyle(ZyquoColor.textPrimary)146 .lineLimit(1)147 .frame(maxWidth: 220, alignment: .leading)148 .onTapGesture(count: 2) {149 titleDraft = task?.title ?? ""150 editingTitle = true151 }152 .help("Double-click to rename")153 }154 }155156 private var headerControls: some View {157 HStack(spacing: ZyquoSpacing.sm) {158 SafetyModePicker(mode: task?.safetyMode ?? .guarded) { mode in159 controller.setSafetyMode(mode)160 }161 if let root = controller.workspaceRoot {162 workspaceChip(root)163 }164 HStack(spacing: ZyquoSpacing.xs) {165 toggleButton(166 symbol: "sidebar.right",167 active: planVisible,168 help: "Toggle plan panel"169 ) { withAnimation(ZyquoMotion.appear) { planVisible.toggle() } }170 toggleButton(171 symbol: "terminal",172 active: drawerVisible,173 help: "Toggle activity drawer"174 ) { withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } }175 Menu {176 Button("Export as Markdown…") {177 if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .markdown) }178 }179 Button("Export as PDF…") {180 if let task { TaskTranscriptExporter.presentSavePanel(for: task, format: .pdf) }181 }182 } label: {183 Image(systemName: "square.and.arrow.up")184 .font(.system(size: 12))185 .foregroundStyle(ZyquoColor.textSecondary)186 }187 .menuStyle(.borderlessButton)188 .menuIndicator(.hidden)189 .fixedSize()190 .help("Export task transcript (Markdown / PDF)")191 Button {192 showingInfo.toggle()193 } label: {194 Image(systemName: "info.circle")195 .font(.system(size: 12))196 .foregroundStyle(ZyquoColor.textSecondary)197 }198 .buttonStyle(.plain)199 .help("Task info")200 .popover(isPresented: $showingInfo, arrowEdge: .bottom) {201 infoPopover202 }203 }204 }205 }206207 private func toggleButton(symbol: String, active: Bool, help: String, action: @escaping () -> Void) -> some View {208 Button(action: action) {209 Image(systemName: symbol)210 .font(.system(size: 12))211 .foregroundStyle(active ? ZyquoColor.accent : ZyquoColor.textSecondary)212 }213 .buttonStyle(.plain)214 .help(help)215 }216217 private func workspaceChip(_ root: URL) -> some View {218 Button {219 NSWorkspace.shared.activateFileViewerSelecting([root])220 } label: {221 HStack(spacing: ZyquoSpacing.xxs) {222 Image(systemName: "folder")223 .font(.system(size: 10))224 Text(root.lastPathComponent)225 .font(ZyquoFont.caption)226 .lineLimit(1)227 }228 .foregroundStyle(ZyquoColor.textSecondary)229 .padding(.horizontal, ZyquoSpacing.xs)230 .padding(.vertical, 3)231 .background(Capsule().fill(ZyquoColor.surfaceSecondary))232 }233 .buttonStyle(.plain)234 .help("Reveal workspace in Finder\n\(root.path)")235 }236237 // MARK: - Info popover238239 private var infoPopover: some View {240 VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {241 Text("Task")242 .font(ZyquoFont.bodyEmphasis())243 let config = controller.loopGuardConfiguration244 LabeledContent("Budgets") {245 Text("\(config.maxSteps) steps · \(config.tokenBudget / 1_000)K tokens · \(Int(config.wallClockBudget / 60)) min")246 }247 LabeledContent("Used this run") {248 Text("\(controller.stepsUsed) steps · \(controller.tokensUsed) tokens")249 }250 if let workspace = controller.workspaceRoot {251 LabeledContent("Workspace") {252 Text(workspace.path)253 .lineLimit(2)254 .truncationMode(.middle)255 }256 }257 Divider()258 Text("System prompt")259 .font(ZyquoFont.caption)260 .foregroundStyle(ZyquoColor.textSecondary)261 ScrollView {262 Text(controller.systemPromptPreview ?? "The full system prompt is assembled when a run starts (role, tools, workspace, safety expectations, done-signal).")263 .font(ZyquoFont.code(size: 10.5))264 .foregroundStyle(ZyquoColor.textSecondary)265 .textSelection(.enabled)266 .frame(maxWidth: .infinity, alignment: .leading)267 .padding(ZyquoSpacing.xs)268 }269 .frame(width: 340, height: 160)270 .background(271 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)272 .fill(ZyquoColor.surfaceSecondary)273 )274 }275 .font(ZyquoFont.body(size: 12.5))276 .padding(ZyquoSpacing.md)277 .frame(width: 380)278 }279280 // MARK: - Empty & no-key states281282 private func emptyState(_ task: AgentTask) -> some View {283 AgentEmptyStateView(284 model: currentModel,285 safetyMode: task.safetyMode,286 personaName: personas.persona(id: task.personaID)?.name,287 onSelectModel: select(model:),288 onSelectSafetyMode: { controller.setSafetyMode($0) },289 onSelectPersona: { persona in select(persona: persona) },290 onBrowseTemplates: { uiState.showTemplateBrowser = true },291 onSuggestion: { draft = $0 }292 )293 }294295 /// Adopts a persona for this task: stores the id and applies its296 /// preferred model / safety default when set.297 private func select(persona: Persona?) {298 guard var task else { return }299 task.personaID = persona?.id300 if let persona, let id = persona.modelID, let provider = persona.provider,301 let preferred = catalog.model(id: id, provider: provider) {302 task.modelID = preferred.id303 task.providerID = preferred.provider304 }305 store.update(task, touch: false)306 if let mode = persona?.safetyMode {307 controller.setSafetyMode(mode)308 }309 }310311 private var noKeyForModel: Bool {312 guard let model = currentModel else { return false }313 return AgentCLI.resolveAPIKey(for: model.provider) == nil314 }315316 private var noKeyBanner: some View {317 HStack(spacing: ZyquoSpacing.xs) {318 Image(systemName: "key")319 .font(.system(size: 11))320 Text("No API key for \(currentModel?.provider.displayName ?? "this provider") yet.")321 .font(ZyquoFont.body(size: 12.5))322 Button("Providers & Keys…") {323 SettingsOpener.open()324 }325 .font(ZyquoFont.body(size: 12.5))326 Spacer(minLength: 0)327 }328 .foregroundStyle(ZyquoColor.warning)329 .padding(.horizontal, ZyquoSpacing.sm)330 .padding(.vertical, ZyquoSpacing.xs)331 .background(332 RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)333 .fill(ZyquoColor.warning.opacity(0.08))334 )335 .padding(.horizontal, ZyquoSpacing.sm)336 }337338 private var inputDisabledReason: String? {339 guard currentModel != nil else { return "Choose a model to run agent tasks." }340 if noKeyForModel {341 return "Add an API key for \(currentModel?.provider.displayName ?? "the provider") to run."342 }343 if let model = currentModel, !model.agentCapable {344 return "\(model.displayName) has limited tool use — agent runs may be unreliable."345 }346 return nil347 }348349 // MARK: - Actions350351 private func run() {352 guard let model = currentModel else { return }353 let prompt = draft354 withAnimation(ZyquoMotion.appear) { draft = "" }355 controller.start(356 prompt: prompt,357 model: model,358 persona: personas.persona(id: task?.personaID)359 )360 }361362 private func select(model: AIModel) {363 guard var task else { return }364 task.modelID = model.id365 task.providerID = model.provider366 store.update(task, touch: false)367 }368369}370