Phase 6 (wave 2): Settings window, Quick Task panel, menu bar extra, templates, personas, ⌘K palette, exports & shortcuts
- Settings scene (760×560, 7 tabs): Providers & Keys (vault UI + env-var indicator + per-provider Test), Models (shared catalog, Agent badges, persisted default agent model), Safety (default mode, allow/deny rule editor over policy-rules.json, read-only built-in destructive-pattern list, AppleScript/workspace-escape settings), Agent (budgets, timeout, parallel tool calls, compaction threshold + Personas CRUD), Appearance (theme/accent swatches/font size), Shortcuts, Advanced (reveal folders, audit-log export, task import/export, menu bar toggle) - Quick Task panel (⌥Space): floating NSPanel, default model + Guarded, live step cards with inline approvals, promote to full window - Menu bar extra with running-task status (toggleable, template icon) - 29 built-in task templates in 6 categories + user templates CRUD, variable fill-in sheet, template browser + ⌘K command palette - Personas: CRUD, per-task picker, system-prompt addendum via AgentConfiguration.personaAddendum → AgentSystemPrompt (## Persona) - Dynamic accent: ZyquoColor.accent/accentSubtle resolve through ZyquoAccentResolver fed by AppearanceStore (token API unchanged) - RunController: settings-driven AgentConfiguration/ExecutionConfiguration, persona pass-through, auto-generated titles after completed runs - Transcript export: Markdown + PDF (TaskTranscriptExporter) - Shortcuts: ⌘K palette, ⌘⇧A audit log, ⌘⇧E export, ⌥Space quick task Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 34 changed files with +3,268 and −174
modified
Sources/ZyquoAgent/Agent/AgentLoop.swift
+5 −1
@@ -29,6 +29,9 @@ struct AgentConfiguration: Sendable { | ||
| 29 | 29 | /// Default per-command timeout for the ExecutionService the host wires up |
| 30 | 30 | /// (the loop itself never spawns processes). |
| 31 | 31 | var perCommandTimeout: TimeInterval? |
| 32 | + /// Active persona's system-prompt addition, appended to the agent system | |
| 33 | + /// prompt as a trailing "## Persona" section (Phase 6 personas). | |
| 34 | + var personaAddendum: String? | |
| 32 | 35 | |
| 33 | 36 | static let `default` = AgentConfiguration() |
| 34 | 37 | } |
@@ -159,7 +162,8 @@ actor AgentLoop { | ||
| 159 | 162 | let systemPrompt = AgentSystemPrompt.build( |
| 160 | 163 | workspacePath: workspace.root.path, |
| 161 | 164 | toolNames: tools.toolNames, |
| 162 | − safetyMode: await policy.mode | |
| 165 | + safetyMode: await policy.mode, | |
| 166 | + personaAddendum: configuration.personaAddendum | |
| 163 | 167 | ) |
| 164 | 168 | let toolSpecs = tools.toolSpecs + [Planner.toolSpec] |
| 165 | 169 | let fixedTokens = MemoryManager.estimateFixedTokens(systemPrompt: systemPrompt, toolSpecs: toolSpecs) |
modified
Sources/ZyquoAgent/Agent/AgentSystemPrompt.swift
+18 −3
@@ -16,17 +16,21 @@ | ||
| 16 | 16 | import Foundation |
| 17 | 17 | |
| 18 | 18 | enum AgentSystemPrompt { |
| 19 | − /// Builds the system prompt for one run. | |
| 19 | + /// Builds the system prompt for one run. `personaAddendum` is the active | |
| 20 | + /// persona's system-prompt addition (Phase 6), appended as its own | |
| 21 | + /// section after the core prompt — the safety/completion contract above | |
| 22 | + /// it always stands. | |
| 20 | 23 | static func build( |
| 21 | 24 | workspacePath: String, |
| 22 | 25 | toolNames: [String], |
| 23 | − safetyMode: SafetyMode | |
| 26 | + safetyMode: SafetyMode, | |
| 27 | + personaAddendum: String? = nil | |
| 24 | 28 | ) -> String { |
| 25 | 29 | let os = ProcessInfo.processInfo.operatingSystemVersionString |
| 26 | 30 | let date = ISO8601DateFormatter().string(from: Date()).prefix(10) |
| 27 | 31 | let tools = toolNames.joined(separator: ", ") |
| 28 | 32 | |
| 29 | − return """ | |
| 33 | + var prompt = """ | |
| 30 | 34 | You are Zyquo Agent, an autonomous agent that operates the user's Mac by calling tools. \ |
| 31 | 35 | You work in a plan → act → observe → reflect loop: issue tool calls, examine each result, \ |
| 32 | 36 | and decide the next action from what you actually observed — never from assumptions. |
@@ -81,5 +85,16 @@ enum AgentSystemPrompt { | ||
| 81 | 85 | - If you cannot complete the task, respond without tool calls explaining what you tried, what \ |
| 82 | 86 | blocks you, and what the user could do. |
| 83 | 87 | """ |
| 88 | + | |
| 89 | + if let personaAddendum, | |
| 90 | + !personaAddendum.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 91 | + prompt += """ | |
| 92 | + | |
| 93 | + | |
| 94 | + ## Persona | |
| 95 | + \(personaAddendum.trimmingCharacters(in: .whitespacesAndNewlines)) | |
| 96 | + """ | |
| 97 | + } | |
| 98 | + return prompt | |
| 84 | 99 | } |
| 85 | 100 | } |
modified
Sources/ZyquoAgent/App/ZyquoAgentApp.swift
+170 −13
@@ -6,9 +6,12 @@ | ||
| 6 | 6 | // Mail: contact@spboucher.ai |
| 7 | 7 | // |
| 8 | 8 | // The SwiftUI app shell: builds the shared environment (task store, run |
| 9 | −// hub, model catalog, key vault, appearance), presents the command-center | |
| 10 | −// window (1320×860 default, 1040×680 min), and wires the wave-1 command set | |
| 11 | −// (⌘N new task; ⌘⏎ run and ⌘. stop are handled inside the detail view). | |
| 9 | +// hub, model catalog, key vault, appearance, agent settings, templates, | |
| 10 | +// personas, UI signals), presents the command-center window (1320×860 | |
| 11 | +// default, 1040×680 min), the Settings scene (760×560, 7 tabs), the | |
| 12 | +// toggleable menu bar extra with running-task status, the ⌥Space Quick Task | |
| 13 | +// panel, and the full Phase 6 command set (⌘N, ⌘K, ⌘⇧A, ⌘⇧E, ⌥Space; | |
| 14 | +// ⌘⏎/⌘./⌘F live in the detail/sidebar views). | |
| 12 | 15 | // |
| 13 | 16 | |
| 14 | 17 | import SwiftUI |
@@ -21,46 +24,200 @@ final class AppEnvironment: ObservableObject { | ||
| 21 | 24 | let catalog: ModelCatalog |
| 22 | 25 | let vault: KeyVaultStore |
| 23 | 26 | let appearance: AppearanceStore |
| 27 | + let settings: AgentSettingsStore | |
| 28 | + let templates: TemplateStore | |
| 29 | + let personas: PersonaStore | |
| 30 | + let uiState: AppUIState | |
| 24 | 31 | |
| 25 | 32 | init() { |
| 26 | 33 | let tasks = TaskStore() |
| 34 | + let settings = AgentSettingsStore() | |
| 27 | 35 | self.tasks = tasks |
| 28 | − self.hub = RunHub(store: tasks) | |
| 36 | + self.settings = settings | |
| 37 | + self.hub = RunHub(store: tasks, settings: settings) | |
| 29 | 38 | self.catalog = ModelCatalog() |
| 30 | 39 | self.vault = KeyVaultStore() |
| 31 | 40 | self.appearance = AppearanceStore() |
| 41 | + self.templates = TemplateStore() | |
| 42 | + self.personas = PersonaStore() | |
| 43 | + self.uiState = AppUIState() | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// The model new tasks start with (persisted default, catalog fallback). | |
| 47 | + var newTaskModel: AIModel? { | |
| 48 | + settings.defaultAgentModel(in: catalog) | |
| 32 | 49 | } |
| 33 | 50 | } |
| 34 | 51 | |
| 35 | 52 | struct ZyquoAgentApp: App { |
| 36 | 53 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| 37 | 54 | @StateObject private var environment = AppEnvironment() |
| 55 | + @State private var quickTask: QuickTaskController? | |
| 56 | + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true | |
| 38 | 57 | |
| 39 | 58 | var body: some Scene { |
| 40 | 59 | WindowGroup("Zyquo Agent") { |
| 41 | 60 | MainWindowView() |
| 42 | − .environmentObject(environment.tasks) | |
| 43 | − .environmentObject(environment.hub) | |
| 44 | − .environmentObject(environment.catalog) | |
| 45 | − .environmentObject(environment.vault) | |
| 46 | − .environmentObject(environment.appearance) | |
| 61 | + .modifier(EnvironmentInjector(environment: environment)) | |
| 47 | 62 | .frame( |
| 48 | 63 | minWidth: ZyquoMetrics.windowMinWidth, |
| 49 | 64 | minHeight: ZyquoMetrics.windowMinHeight |
| 50 | 65 | ) |
| 66 | + .onAppear { | |
| 67 | + if quickTask == nil { | |
| 68 | + quickTask = QuickTaskController(environment: environment) | |
| 69 | + } | |
| 70 | + } | |
| 51 | 71 | } |
| 52 | 72 | .defaultSize( |
| 53 | 73 | width: ZyquoMetrics.windowDefaultWidth, |
| 54 | 74 | height: ZyquoMetrics.windowDefaultHeight |
| 55 | 75 | ) |
| 56 | 76 | .commands { |
| 57 | − CommandGroup(replacing: .newItem) { | |
| 58 | − Button("New Task") { | |
| 59 | − environment.tasks.newTask(model: environment.catalog.defaultAgentModel) | |
| 77 | + AppCommands(environment: environment, quickTask: { quickTask }) | |
| 78 | + } | |
| 79 | + | |
| 80 | + Settings { | |
| 81 | + SettingsView() | |
| 82 | + .modifier(EnvironmentInjector(environment: environment)) | |
| 83 | + } | |
| 84 | + | |
| 85 | + MenuBarExtra(isInserted: $menuBarExtraEnabled) { | |
| 86 | + MenuBarContent(store: environment.tasks, environment: environment, quickTask: { quickTask }) | |
| 87 | + } label: { | |
| 88 | + MenuBarIconView() | |
| 89 | + } | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Injects the full shared object graph (all scenes get the same set). | |
| 94 | +private struct EnvironmentInjector: ViewModifier { | |
| 95 | + let environment: AppEnvironment | |
| 96 | + | |
| 97 | + func body(content: Content) -> some View { | |
| 98 | + content | |
| 99 | + .environmentObject(environment.tasks) | |
| 100 | + .environmentObject(environment.hub) | |
| 101 | + .environmentObject(environment.catalog) | |
| 102 | + .environmentObject(environment.vault) | |
| 103 | + .environmentObject(environment.appearance) | |
| 104 | + .environmentObject(environment.settings) | |
| 105 | + .environmentObject(environment.templates) | |
| 106 | + .environmentObject(environment.personas) | |
| 107 | + .environmentObject(environment.uiState) | |
| 108 | + } | |
| 109 | +} | |
| 110 | + | |
| 111 | +// MARK: - Commands | |
| 112 | + | |
| 113 | +/// App-level menu commands and shortcuts (⌘N, ⌘K, ⌘⇧A, ⌘⇧E, Quick Task). | |
| 114 | +struct AppCommands: Commands { | |
| 115 | + let environment: AppEnvironment | |
| 116 | + var quickTask: () -> QuickTaskController? | |
| 117 | + | |
| 118 | + var body: some Commands { | |
| 119 | + CommandGroup(replacing: .newItem) { | |
| 120 | + Button("New Task") { | |
| 121 | + environment.tasks.newTask( | |
| 122 | + model: environment.newTaskModel, | |
| 123 | + safetyMode: environment.settings.settings.defaultSafetyMode | |
| 124 | + ) | |
| 125 | + } | |
| 126 | + .keyboardShortcut("n", modifiers: .command) | |
| 127 | + } | |
| 128 | + CommandMenu("Task") { | |
| 129 | + Button("Command Palette") { | |
| 130 | + environment.uiState.showCommandPalette.toggle() | |
| 131 | + } | |
| 132 | + .keyboardShortcut("k", modifiers: .command) | |
| 133 | + Button("Quick Task") { | |
| 134 | + quickTask()?.show() | |
| 135 | + } | |
| 136 | + Button("Browse Templates…") { | |
| 137 | + environment.uiState.showTemplateBrowser = true | |
| 138 | + } | |
| 139 | + Divider() | |
| 140 | + Button("Open Audit Log") { | |
| 141 | + environment.uiState.requestAuditLog() | |
| 142 | + } | |
| 143 | + .keyboardShortcut("a", modifiers: [.command, .shift]) | |
| 144 | + Divider() | |
| 145 | + Button("Export Transcript as Markdown…") { | |
| 146 | + if let id = environment.tasks.selectedID, let task = environment.tasks.task(id: id) { | |
| 147 | + TaskTranscriptExporter.presentSavePanel(for: task, format: .markdown) | |
| 148 | + } | |
| 149 | + } | |
| 150 | + .keyboardShortcut("e", modifiers: [.command, .shift]) | |
| 151 | + .disabled(environment.tasks.selectedID == nil) | |
| 152 | + Button("Export Transcript as PDF…") { | |
| 153 | + if let id = environment.tasks.selectedID, let task = environment.tasks.task(id: id) { | |
| 154 | + TaskTranscriptExporter.presentSavePanel(for: task, format: .pdf) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + .disabled(environment.tasks.selectedID == nil) | |
| 158 | + } | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +// MARK: - Menu bar extra | |
| 163 | + | |
| 164 | +/// Menu bar glyph: the shipped template PNG when bundled, SF Symbol fallback | |
| 165 | +/// during `swift run` (no bundle resources). | |
| 166 | +struct MenuBarIconView: View { | |
| 167 | + var body: some View { | |
| 168 | + if let image = Self.templateImage() { | |
| 169 | + Image(nsImage: image) | |
| 170 | + } else { | |
| 171 | + Image(systemName: "bolt.circle") | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + private static func templateImage() -> NSImage? { | |
| 176 | + guard let path = Bundle.main.path(forResource: "MenuBarIcon", ofType: "png"), | |
| 177 | + let image = NSImage(contentsOfFile: path) else { return nil } | |
| 178 | + image.isTemplate = true | |
| 179 | + image.size = NSSize(width: 18, height: 18) | |
| 180 | + return image | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +/// Menu content: running-task status, New Task, Quick Task, open, quit. | |
| 185 | +struct MenuBarContent: View { | |
| 186 | + @ObservedObject var store: TaskStore | |
| 187 | + let environment: AppEnvironment | |
| 188 | + var quickTask: () -> QuickTaskController? | |
| 189 | + | |
| 190 | + var body: some View { | |
| 191 | + let running = store.tasks.filter { $0.status.isActive } | |
| 192 | + if running.isEmpty { | |
| 193 | + Text("No running tasks") | |
| 194 | + } else { | |
| 195 | + Text("\(running.count) running task\(running.count == 1 ? "" : "s")") | |
| 196 | + ForEach(running) { task in | |
| 197 | + Button("\(task.title) — \(task.status.displayName)") { | |
| 198 | + store.selectedID = task.id | |
| 199 | + NSApp.activate(ignoringOtherApps: true) | |
| 60 | 200 | } |
| 61 | − .keyboardShortcut("n", modifiers: .command) | |
| 62 | 201 | } |
| 63 | 202 | } |
| 203 | + Divider() | |
| 204 | + Button("New Task") { | |
| 205 | + store.newTask( | |
| 206 | + model: environment.newTaskModel, | |
| 207 | + safetyMode: environment.settings.settings.defaultSafetyMode | |
| 208 | + ) | |
| 209 | + NSApp.activate(ignoringOtherApps: true) | |
| 210 | + } | |
| 211 | + Button("Quick Task ⌥Space") { | |
| 212 | + quickTask()?.show() | |
| 213 | + } | |
| 214 | + Divider() | |
| 215 | + Button("Open Zyquo Agent") { | |
| 216 | + NSApp.activate(ignoringOtherApps: true) | |
| 217 | + } | |
| 218 | + Button("Quit") { | |
| 219 | + NSApp.terminate(nil) | |
| 220 | + } | |
| 64 | 221 | } |
| 65 | 222 | } |
| 66 | 223 | |
modified
Sources/ZyquoAgent/DesignSystem/AppearanceStore.swift
+9 −1
@@ -89,7 +89,14 @@ final class AppearanceStore: ObservableObject { | ||
| 89 | 89 | static let fileName = "appearance.json" |
| 90 | 90 | |
| 91 | 91 | @Published var themeMode: ThemeMode { didSet { save() } } |
| 92 | − @Published var accent: AccentChoice { didSet { save() } } | |
| 92 | + @Published var accent: AccentChoice { | |
| 93 | + didSet { | |
| 94 | + // Feed the design-system resolver so the static ZyquoColor.accent | |
| 95 | + // token picks up the new choice on the next draw. | |
| 96 | + ZyquoAccentResolver.current = accent | |
| 97 | + save() | |
| 98 | + } | |
| 99 | + } | |
| 93 | 100 | /// Chat body font size, clamped to the spec's 12–18pt range. |
| 94 | 101 | @Published var chatFontSize: Double { |
| 95 | 102 | didSet { |
@@ -107,6 +114,7 @@ final class AppearanceStore: ObservableObject { | ||
| 107 | 114 | themeMode = stored.themeMode |
| 108 | 115 | accent = stored.accent |
| 109 | 116 | chatFontSize = stored.chatFontSize |
| 117 | + ZyquoAccentResolver.current = stored.accent | |
| 110 | 118 | } |
| 111 | 119 | |
| 112 | 120 | /// Current accent color resolved for the active appearance. |
modified
Sources/ZyquoAgent/DesignSystem/ZyquoTheme.swift
+23 −4
@@ -25,10 +25,21 @@ enum ZyquoColor { | ||
| 25 | 25 | static let surface = dynamic(light: 0xFFFFFF, dark: 0x1E1B2A) |
| 26 | 26 | /// Hover states, code/terminal block background. |
| 27 | 27 | static let surfaceSecondary = dynamic(light: 0xF4F2F8, dark: 0x262233) |
| 28 | − /// Primary actions, selection, links, run button (confident violet). | |
| 29 | − static let accent = dynamic(light: 0x7A5AF0, dark: 0x9B82F6) | |
| 30 | − /// Selected task rows, user bubble tint. | |
| 31 | − static let accentSubtle = dynamic(light: 0xEFEBFD, dark: 0x2E2749) | |
| 28 | + /// Primary actions, selection, links, run button (confident violet by | |
| 29 | + /// default). The dynamic provider reads the CURRENT accent choice from | |
| 30 | + /// ZyquoAccentResolver at draw time, so the whole token API stays static | |
| 31 | + /// while Settings › Appearance swaps the accent live. | |
| 32 | + static let accent = Color(nsColor: NSColor(name: nil) { appearance in | |
| 33 | + let (light, dark) = ZyquoAccentResolver.current.accentHex | |
| 34 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 35 | + return NSColor(hex: hex) | |
| 36 | + }) | |
| 37 | + /// Selected task rows, user bubble tint (follows the accent choice). | |
| 38 | + static let accentSubtle = Color(nsColor: NSColor(name: nil) { appearance in | |
| 39 | + let (light, dark) = ZyquoAccentResolver.current.subtleHex | |
| 40 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 41 | + return NSColor(hex: hex) | |
| 42 | + }) | |
| 32 | 43 | static let textPrimary = dynamic(light: 0x1B1A20, dark: 0xEAE8F0) |
| 33 | 44 | static let textSecondary = dynamic(light: 0x6E6B78, dark: 0x9C98AA) |
| 34 | 45 | static let textTertiary = dynamic(light: 0xA09DAC, dark: 0x6B6878) |
@@ -48,6 +59,14 @@ enum ZyquoColor { | ||
| 48 | 59 | } |
| 49 | 60 | } |
| 50 | 61 | |
| 62 | +/// Holds the accent choice ZyquoColor's dynamic providers read at draw time. | |
| 63 | +/// Written only by AppearanceStore on the main thread (init + user change); | |
| 64 | +/// scene roots re-identify on accent change so every view redraws with the | |
| 65 | +/// new value immediately. | |
| 66 | +enum ZyquoAccentResolver { | |
| 67 | + static var current: AccentChoice = .violet | |
| 68 | +} | |
| 69 | + | |
| 51 | 70 | extension NSColor { |
| 52 | 71 | /// 0xRRGGBB → NSColor (sRGB). |
| 53 | 72 | convenience init(hex: UInt32) { |
modified
Sources/ZyquoAgent/Execution/PolicyEngine.swift
+38 −0
@@ -464,6 +464,44 @@ enum ShellCommandAnalyzer { | ||
| 464 | 464 | "chmod", "chown", "chflags", "truncate", "dd", "rsync", "unzip", "tar" |
| 465 | 465 | ] |
| 466 | 466 | |
| 467 | + // MARK: Built-in pattern descriptions (Settings › Safety, read-only) | |
| 468 | + | |
| 469 | + /// Human-readable descriptions of the built-in hard-deny patterns — | |
| 470 | + /// actions that never run, in any mode. Display-only mirror of the | |
| 471 | + /// classification logic above; the logic itself is the source of truth. | |
| 472 | + static let hardDenyDescriptions: [String] = [ | |
| 473 | + "Fork bombs (`:(){ :|:& };:` and renamed variants)", | |
| 474 | + "Filesystem creation — `mkfs*`, `newfs_apfs`, `newfs_hfs` (destroys the target volume)", | |
| 475 | + "`diskutil erase* / partitionDisk / zeroDisk / reformat / apfs delete…`", | |
| 476 | + "`dd` writing directly to a device node (`of=/dev/…`)", | |
| 477 | + "`rm` targeting `/`, `/*`, or the entire home directory", | |
| 478 | + "Any write into `/System` (protected by System Integrity Protection)", | |
| 479 | + "Commands matching one of your deny rules", | |
| 480 | + ] | |
| 481 | + | |
| 482 | + /// Human-readable descriptions of the always-ask circuit breakers — | |
| 483 | + /// actions that require approval in EVERY mode, including Autonomous; | |
| 484 | + /// remembered allow rules can never override them. | |
| 485 | + static let alwaysAskDescriptions: [String] = [ | |
| 486 | + "`sudo` / `doas` — elevated privileges are never run silently", | |
| 487 | + "`shutdown`, `reboot`, `halt`", | |
| 488 | + "`kill`, `pkill`, `killall` — terminating processes", | |
| 489 | + "`launchctl` — modifying launchd services", | |
| 490 | + "`systemsetup`, `csrutil`, `nvram`, `spctl` — system-level configuration", | |
| 491 | + "`security` — keychain access", | |
| 492 | + "`defaults write` / `defaults delete` — preference changes", | |
| 493 | + "`installer`, `softwareupdate --install` — system-wide installs", | |
| 494 | + "`git push --force` — rewriting remote history", | |
| 495 | + "AppleScript requesting administrator privileges", | |
| 496 | + "Recursive `chmod`/`chown`/`chflags` outside the workspace", | |
| 497 | + "`rm -rf` (or any deletion) on paths outside the task workspace", | |
| 498 | + "`mv`/`cp` whose destination escapes the workspace or targets a system path", | |
| 499 | + "Output redirection (`>`/`>>`) to files outside the workspace or with unresolvable variables", | |
| 500 | + "Network downloads piped into an interpreter (`curl … | sh`)", | |
| 501 | + "`find -exec rm/mv/chmod/chown/shred` — mass file modification", | |
| 502 | + "Any file read or write outside the task workspace (FileTools)", | |
| 503 | + ] | |
| 504 | + | |
| 467 | 505 | // MARK: Entry point |
| 468 | 506 | |
| 469 | 507 | static func analyze( |
modified
Sources/ZyquoAgent/Models/AgentTask.swift
+3 −0
@@ -85,6 +85,9 @@ struct AgentTask: Codable, Identifiable, Sendable { | ||
| 85 | 85 | var modelID: String |
| 86 | 86 | var providerID: ProviderID |
| 87 | 87 | var safetyMode: SafetyMode = .guarded |
| 88 | + /// Active persona (Phase 6): its system-prompt addition is appended to | |
| 89 | + /// the agent system prompt on every run of this task; nil = none. | |
| 90 | + var personaID: UUID? | |
| 88 | 91 | /// Path of the task's workspace directory; nil until the first run |
| 89 | 92 | /// creates it. |
| 90 | 93 | var workspacePath: String? |
modified
Sources/ZyquoAgent/Models/ChatParameters.swift
+7 −1
@@ -27,6 +27,8 @@ struct ChatParameters: Codable, Hashable { | ||
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | /// A reusable configuration: system prompt + preferred model + parameters. |
| 30 | +/// Zyquo Agent adds `safetyMode` — the safety default a persona applies to | |
| 31 | +/// new tasks (nil = keep the app default). | |
| 30 | 32 | struct Persona: Codable, Identifiable, Hashable { |
| 31 | 33 | let id: UUID |
| 32 | 34 | var name: String |
@@ -36,6 +38,8 @@ struct Persona: Codable, Identifiable, Hashable { | ||
| 36 | 38 | var modelID: String? |
| 37 | 39 | var provider: ProviderID? |
| 38 | 40 | var parameters: ChatParameters |
| 41 | + /// Default safety mode applied when a new task adopts this persona. | |
| 42 | + var safetyMode: SafetyMode? | |
| 39 | 43 | |
| 40 | 44 | init( |
| 41 | 45 | id: UUID = UUID(), |
@@ -44,7 +48,8 @@ struct Persona: Codable, Identifiable, Hashable { | ||
| 44 | 48 | systemPrompt: String, |
| 45 | 49 | modelID: String? = nil, |
| 46 | 50 | provider: ProviderID? = nil, |
| 47 | − parameters: ChatParameters = ChatParameters() | |
| 51 | + parameters: ChatParameters = ChatParameters(), | |
| 52 | + safetyMode: SafetyMode? = nil | |
| 48 | 53 | ) { |
| 49 | 54 | self.id = id |
| 50 | 55 | self.name = name |
@@ -53,5 +58,6 @@ struct Persona: Codable, Identifiable, Hashable { | ||
| 53 | 58 | self.modelID = modelID |
| 54 | 59 | self.provider = provider |
| 55 | 60 | self.parameters = parameters |
| 61 | + self.safetyMode = safetyMode | |
| 56 | 62 | } |
| 57 | 63 | } |
added
Sources/ZyquoAgent/Models/TaskTemplate.swift
+310 −0
@@ -0,0 +1,310 @@ | ||
| 1 | +// | |
| 2 | +// TaskTemplate.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Ready-made agent task templates (Phase 6): a title, category, prompt with | |
| 9 | +// {{variable}} placeholders, and a suggested safety mode. The built-in | |
| 10 | +// library ships ≥25 genuinely useful tasks; user templates layer on top | |
| 11 | +// (TemplateStore). Using a template with variables opens the fill-in sheet. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Foundation | |
| 15 | + | |
| 16 | +/// Template grouping shown in the browser and the command palette. | |
| 17 | +enum TemplateCategory: String, Codable, CaseIterable, Identifiable { | |
| 18 | + case filesAndFolders | |
| 19 | + case development | |
| 20 | + case automation | |
| 21 | + case data | |
| 22 | + case systemInfo | |
| 23 | + case writing | |
| 24 | + | |
| 25 | + var id: String { rawValue } | |
| 26 | + | |
| 27 | + var displayName: String { | |
| 28 | + switch self { | |
| 29 | + case .filesAndFolders: return "Files & Folders" | |
| 30 | + case .development: return "Development" | |
| 31 | + case .automation: return "Automation (AppleScript)" | |
| 32 | + case .data: return "Data" | |
| 33 | + case .systemInfo: return "System Info" | |
| 34 | + case .writing: return "Writing" | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + var symbolName: String { | |
| 39 | + switch self { | |
| 40 | + case .filesAndFolders: return "folder" | |
| 41 | + case .development: return "chevron.left.forwardslash.chevron.right" | |
| 42 | + case .automation: return "applescript" | |
| 43 | + case .data: return "tablecells" | |
| 44 | + case .systemInfo: return "cpu" | |
| 45 | + case .writing: return "text.quote" | |
| 46 | + } | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +/// One reusable agent task. `prompt` may contain `{{variable}}` placeholders | |
| 51 | +/// filled in by the user before the task is created. | |
| 52 | +struct TaskTemplate: Codable, Identifiable, Hashable { | |
| 53 | + var id: UUID = UUID() | |
| 54 | + var title: String | |
| 55 | + var category: TemplateCategory | |
| 56 | + /// SF Symbol shown next to the title (defaults to the category glyph). | |
| 57 | + var symbolName: String? | |
| 58 | + var prompt: String | |
| 59 | + var suggestedSafetyMode: SafetyMode = .guarded | |
| 60 | + /// Built-ins are read-only; user templates are editable/deletable. | |
| 61 | + var isBuiltIn: Bool = false | |
| 62 | + | |
| 63 | + var displaySymbol: String { symbolName ?? category.symbolName } | |
| 64 | + | |
| 65 | + /// Distinct `{{variable}}` names in declaration order. | |
| 66 | + var variables: [String] { | |
| 67 | + Self.variables(in: prompt) | |
| 68 | + } | |
| 69 | + | |
| 70 | + /// Extracts distinct `{{name}}` placeholders from a prompt. | |
| 71 | + static func variables(in prompt: String) -> [String] { | |
| 72 | + var names: [String] = [] | |
| 73 | + var rest = Substring(prompt) | |
| 74 | + while let open = rest.range(of: "{{"), let close = rest[open.upperBound...].range(of: "}}") { | |
| 75 | + let name = rest[open.upperBound..<close.lowerBound] | |
| 76 | + .trimmingCharacters(in: .whitespaces) | |
| 77 | + if !name.isEmpty, !names.contains(name) { | |
| 78 | + names.append(name) | |
| 79 | + } | |
| 80 | + rest = rest[close.upperBound...] | |
| 81 | + } | |
| 82 | + return names | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// The prompt with every `{{variable}}` replaced by its filled value. | |
| 86 | + func renderedPrompt(values: [String: String]) -> String { | |
| 87 | + var rendered = prompt | |
| 88 | + for (name, value) in values { | |
| 89 | + rendered = rendered.replacingOccurrences(of: "{{\(name)}}", with: value) | |
| 90 | + rendered = rendered.replacingOccurrences(of: "{{ \(name) }}", with: value) | |
| 91 | + } | |
| 92 | + return rendered | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +// MARK: - Built-in library (≥25 templates) | |
| 97 | + | |
| 98 | +enum TaskTemplateLibrary { | |
| 99 | + static let builtIn: [TaskTemplate] = [ | |
| 100 | + // ---- Files & Folders (6) --------------------------------------- | |
| 101 | + TaskTemplate( | |
| 102 | + title: "Organize my Downloads folder", | |
| 103 | + category: .filesAndFolders, | |
| 104 | + symbolName: "folder.badge.gearshape", | |
| 105 | + prompt: "Look at my Downloads folder (~/Downloads), group the files by type into subfolders (Images, Documents, Archives, Installers, Audio, Video, Other), move them accordingly, and show me a summary table of what you moved. Do not delete anything.", | |
| 106 | + isBuiltIn: true | |
| 107 | + ), | |
| 108 | + TaskTemplate( | |
| 109 | + title: "Batch-rename files by pattern", | |
| 110 | + category: .filesAndFolders, | |
| 111 | + symbolName: "textformat.abc.dottedunderline", | |
| 112 | + prompt: "Rename all files in {{folder}} to a consistent kebab-case pattern with a zero-padded numeric suffix ({{prefix}}-01, {{prefix}}-02, …), keeping extensions, and list the before → after mapping.", | |
| 113 | + isBuiltIn: true | |
| 114 | + ), | |
| 115 | + TaskTemplate( | |
| 116 | + title: "Find my largest files", | |
| 117 | + category: .filesAndFolders, | |
| 118 | + prompt: "Find the 20 largest files under {{folder}} (skip hidden files and app bundles), and present them as a table with size, path, and last-modified date. Do not modify anything.", | |
| 119 | + isBuiltIn: true | |
| 120 | + ), | |
| 121 | + TaskTemplate( | |
| 122 | + title: "Deduplicate a folder", | |
| 123 | + category: .filesAndFolders, | |
| 124 | + symbolName: "doc.on.doc", | |
| 125 | + prompt: "Scan {{folder}} for duplicate files (same content, compare by checksum). Report every duplicate group with paths and sizes, then move the redundant copies (keep the oldest of each group) into a 'Duplicates' subfolder — never delete outright.", | |
| 126 | + isBuiltIn: true | |
| 127 | + ), | |
| 128 | + TaskTemplate( | |
| 129 | + title: "Archive old files", | |
| 130 | + category: .filesAndFolders, | |
| 131 | + symbolName: "archivebox", | |
| 132 | + prompt: "In {{folder}}, find files not modified in the last {{months}} months, move them into an 'Archive-{{months}}mo' subfolder preserving their relative structure, then zip that subfolder and report the space it occupies.", | |
| 133 | + isBuiltIn: true | |
| 134 | + ), | |
| 135 | + TaskTemplate( | |
| 136 | + title: "Build a folder structure from a spec", | |
| 137 | + category: .filesAndFolders, | |
| 138 | + symbolName: "folder.badge.plus", | |
| 139 | + prompt: "Create this folder structure in the workspace and put a short README.md in each leaf folder describing its purpose:\n\n{{structure}}", | |
| 140 | + isBuiltIn: true | |
| 141 | + ), | |
| 142 | + | |
| 143 | + // ---- Development (6) -------------------------------------------- | |
| 144 | + TaskTemplate( | |
| 145 | + title: "Set up a Python project and run the tests", | |
| 146 | + category: .development, | |
| 147 | + prompt: "Create a small Python project in the workspace with a src/ layout, one example module with two functions, pytest tests for them, then run the tests and report the results.", | |
| 148 | + isBuiltIn: true | |
| 149 | + ), | |
| 150 | + TaskTemplate( | |
| 151 | + title: "Scaffold a Node.js CLI tool", | |
| 152 | + category: .development, | |
| 153 | + symbolName: "terminal", | |
| 154 | + prompt: "Scaffold a Node.js command-line tool named {{name}} in the workspace: package.json with a bin entry, a src/index.js implementing {{description}}, and a README. Run it once with --help to verify it works.", | |
| 155 | + isBuiltIn: true | |
| 156 | + ), | |
| 157 | + TaskTemplate( | |
| 158 | + title: "Initialize a git repository with hygiene files", | |
| 159 | + category: .development, | |
| 160 | + symbolName: "arrow.triangle.branch", | |
| 161 | + prompt: "Initialize a git repository in the workspace with a sensible .gitignore for {{language}}, a README.md skeleton, an MIT LICENSE with the current year, and make the initial commit. Show the resulting git log.", | |
| 162 | + isBuiltIn: true | |
| 163 | + ), | |
| 164 | + TaskTemplate( | |
| 165 | + title: "Explain and fix a failing script", | |
| 166 | + category: .development, | |
| 167 | + symbolName: "ladybug", | |
| 168 | + prompt: "Here is a script that fails. Save it in the workspace, run it, diagnose the failure from the actual output, fix it, re-run to verify, and explain the root cause:\n\n{{script}}", | |
| 169 | + isBuiltIn: true | |
| 170 | + ), | |
| 171 | + TaskTemplate( | |
| 172 | + title: "Write a script to automate a chore", | |
| 173 | + category: .development, | |
| 174 | + symbolName: "wand.and.stars", | |
| 175 | + prompt: "Write a well-commented shell script in the workspace that {{chore}}. Test it against sample data you create in the workspace first, show the output, and explain how to use it.", | |
| 176 | + isBuiltIn: true | |
| 177 | + ), | |
| 178 | + TaskTemplate( | |
| 179 | + title: "Profile a directory's code statistics", | |
| 180 | + category: .development, | |
| 181 | + symbolName: "chart.bar", | |
| 182 | + prompt: "Analyze the source code under {{folder}}: count files and lines per language/extension, find the 10 longest files, and summarize the project layout in a short report saved as report.md in the workspace.", | |
| 183 | + isBuiltIn: true | |
| 184 | + ), | |
| 185 | + | |
| 186 | + // ---- Automation (AppleScript) (5) -------------------------------- | |
| 187 | + TaskTemplate( | |
| 188 | + title: "Export my Notes to Markdown", | |
| 189 | + category: .automation, | |
| 190 | + symbolName: "note.text", | |
| 191 | + prompt: "Use AppleScript to read my Apple Notes and export each note in the default folder as a Markdown file in the workspace, named after its title.", | |
| 192 | + isBuiltIn: true | |
| 193 | + ), | |
| 194 | + TaskTemplate( | |
| 195 | + title: "Create a reminder", | |
| 196 | + category: .automation, | |
| 197 | + symbolName: "checklist", | |
| 198 | + prompt: "Use AppleScript to create a reminder in the Reminders app titled \"{{title}}\" due {{due}}. Confirm it was created by reading it back.", | |
| 199 | + isBuiltIn: true | |
| 200 | + ), | |
| 201 | + TaskTemplate( | |
| 202 | + title: "List today's calendar events", | |
| 203 | + category: .automation, | |
| 204 | + symbolName: "calendar", | |
| 205 | + prompt: "Use AppleScript to read today's events from the Calendar app and present them as a Markdown agenda (time, title, calendar name). Save it as agenda.md in the workspace.", | |
| 206 | + isBuiltIn: true | |
| 207 | + ), | |
| 208 | + TaskTemplate( | |
| 209 | + title: "Tidy my Desktop into a dated folder", | |
| 210 | + category: .automation, | |
| 211 | + symbolName: "menubar.dock.rectangle", | |
| 212 | + prompt: "Use Finder automation (AppleScript or shell) to move everything currently on my Desktop into a new folder named 'Desktop {{date}}' inside ~/Documents, then list what was moved.", | |
| 213 | + isBuiltIn: true | |
| 214 | + ), | |
| 215 | + TaskTemplate( | |
| 216 | + title: "Draft an email in Mail", | |
| 217 | + category: .automation, | |
| 218 | + symbolName: "envelope", | |
| 219 | + prompt: "Use AppleScript to create a DRAFT (do not send) in Apple Mail addressed to {{recipient}} with subject \"{{subject}}\". Write the body from these points: {{points}}. Leave it open for my review.", | |
| 220 | + isBuiltIn: true | |
| 221 | + ), | |
| 222 | + | |
| 223 | + // ---- Data (4) ----------------------------------------------------- | |
| 224 | + TaskTemplate( | |
| 225 | + title: "Parse a CSV and summarize it", | |
| 226 | + category: .data, | |
| 227 | + prompt: "Read the CSV file at {{path}}, describe its columns and row count, compute basic statistics for the numeric columns (min/max/mean), surface anything anomalous, and save the summary as summary.md in the workspace.", | |
| 228 | + isBuiltIn: true | |
| 229 | + ), | |
| 230 | + TaskTemplate( | |
| 231 | + title: "Convert JSON to CSV", | |
| 232 | + category: .data, | |
| 233 | + symbolName: "arrow.left.arrow.right", | |
| 234 | + prompt: "Read the JSON file at {{path}}, flatten its records sensibly, write them as a CSV in the workspace, and show me the header plus the first 5 rows.", | |
| 235 | + isBuiltIn: true | |
| 236 | + ), | |
| 237 | + TaskTemplate( | |
| 238 | + title: "Aggregate a log file", | |
| 239 | + category: .data, | |
| 240 | + symbolName: "doc.plaintext", | |
| 241 | + prompt: "Analyze the log file at {{path}}: count entries per severity level, extract the 10 most frequent error messages with counts, note the time range covered, and write findings.md in the workspace.", | |
| 242 | + isBuiltIn: true | |
| 243 | + ), | |
| 244 | + TaskTemplate( | |
| 245 | + title: "Diff two files and explain the changes", | |
| 246 | + category: .data, | |
| 247 | + symbolName: "plus.forwardslash.minus", | |
| 248 | + prompt: "Compare {{fileA}} and {{fileB}} with diff, then explain the meaningful differences in plain language, grouped by theme, and save the annotated diff in the workspace.", | |
| 249 | + isBuiltIn: true | |
| 250 | + ), | |
| 251 | + | |
| 252 | + // ---- System Info (4) ----------------------------------------------- | |
| 253 | + TaskTemplate( | |
| 254 | + title: "Storage health report", | |
| 255 | + category: .systemInfo, | |
| 256 | + symbolName: "internaldrive", | |
| 257 | + prompt: "Produce a storage report for this Mac: total/used/free disk space, the 10 largest folders in my home directory (one level deep), and cache folders that look safely cleanable. Report only — do not delete anything.", | |
| 258 | + isBuiltIn: true | |
| 259 | + ), | |
| 260 | + TaskTemplate( | |
| 261 | + title: "Snapshot my Mac's configuration", | |
| 262 | + category: .systemInfo, | |
| 263 | + prompt: "Collect a configuration snapshot: macOS version, hardware model, CPU/RAM, uptime, network interfaces with IPs, and installed developer toolchains (git, node, python3, swift — with versions). Save it as system-snapshot.md in the workspace.", | |
| 264 | + isBuiltIn: true | |
| 265 | + ), | |
| 266 | + TaskTemplate( | |
| 267 | + title: "What is using my resources right now?", | |
| 268 | + category: .systemInfo, | |
| 269 | + symbolName: "gauge.with.needle", | |
| 270 | + prompt: "Show the 10 processes using the most CPU and the 10 using the most memory right now, with a one-line interpretation of anything unusual. Read-only — do not kill anything.", | |
| 271 | + isBuiltIn: true | |
| 272 | + ), | |
| 273 | + TaskTemplate( | |
| 274 | + title: "Audit my login items and launch agents", | |
| 275 | + category: .systemInfo, | |
| 276 | + symbolName: "power", | |
| 277 | + prompt: "List my user LaunchAgents (~/Library/LaunchAgents) and the system ones, with each plist's program and schedule, and flag anything that looks like abandoned software. Read-only — change nothing.", | |
| 278 | + isBuiltIn: true | |
| 279 | + ), | |
| 280 | + | |
| 281 | + // ---- Writing (4) ---------------------------------------------------- | |
| 282 | + TaskTemplate( | |
| 283 | + title: "Summarize a document", | |
| 284 | + category: .writing, | |
| 285 | + prompt: "Read the document at {{path}} and write a structured summary (TL;DR, key points, open questions) as summary.md in the workspace, keeping it under 400 words.", | |
| 286 | + isBuiltIn: true | |
| 287 | + ), | |
| 288 | + TaskTemplate( | |
| 289 | + title: "Draft a README for a project", | |
| 290 | + category: .writing, | |
| 291 | + symbolName: "book", | |
| 292 | + prompt: "Inspect the project at {{folder}} (layout, manifest files, entry points) and draft a complete README.md in the workspace: what it is, how to install, how to run, and project structure.", | |
| 293 | + isBuiltIn: true | |
| 294 | + ), | |
| 295 | + TaskTemplate( | |
| 296 | + title: "Turn rough notes into a document", | |
| 297 | + category: .writing, | |
| 298 | + symbolName: "square.and.pencil", | |
| 299 | + prompt: "Turn these rough notes into a well-structured Markdown document with headings, saved as {{filename}}.md in the workspace:\n\n{{notes}}", | |
| 300 | + isBuiltIn: true | |
| 301 | + ), | |
| 302 | + TaskTemplate( | |
| 303 | + title: "Weekly review from my file activity", | |
| 304 | + category: .writing, | |
| 305 | + symbolName: "calendar.badge.clock", | |
| 306 | + prompt: "Find files under {{folder}} modified in the last 7 days, group them by project/folder, and draft a short weekly review (what moved, what looks stalled) as weekly-review.md in the workspace.", | |
| 307 | + isBuiltIn: true | |
| 308 | + ), | |
| 309 | + ] | |
| 310 | +} | |
added
Sources/ZyquoAgent/Services/TaskTranscriptExporter.swift
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +// | |
| 2 | +// TaskTranscriptExporter.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Exports a task's transcript (prompts, run steps with tool calls/results, | |
| 9 | +// final answers) as Markdown or PDF via NSSavePanel. The PDF path stays | |
| 10 | +// deliberately simple: the Markdown text set on an off-screen NSTextView, | |
| 11 | +// rendered with dataWithPDF (standard AppKit, no print dialog). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import AppKit | |
| 15 | +import Foundation | |
| 16 | +import UniformTypeIdentifiers | |
| 17 | + | |
| 18 | +@MainActor | |
| 19 | +enum TaskTranscriptExporter { | |
| 20 | + enum Format { | |
| 21 | + case markdown | |
| 22 | + case pdf | |
| 23 | + } | |
| 24 | + | |
| 25 | + /// Presents the save panel and writes the transcript in the given format. | |
| 26 | + static func presentSavePanel(for task: AgentTask, format: Format) { | |
| 27 | + let panel = NSSavePanel() | |
| 28 | + switch format { | |
| 29 | + case .markdown: | |
| 30 | + panel.allowedContentTypes = [.plainText] | |
| 31 | + panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).md" | |
| 32 | + case .pdf: | |
| 33 | + panel.allowedContentTypes = [.pdf] | |
| 34 | + panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).pdf" | |
| 35 | + } | |
| 36 | + let markdown = markdown(for: task) | |
| 37 | + panel.begin { response in | |
| 38 | + guard response == .OK, let url = panel.url else { return } | |
| 39 | + Task { @MainActor in | |
| 40 | + switch format { | |
| 41 | + case .markdown: | |
| 42 | + try? markdown.write(to: url, atomically: true, encoding: .utf8) | |
| 43 | + case .pdf: | |
| 44 | + if let data = pdfData(from: markdown, title: task.title) { | |
| 45 | + try? data.write(to: url, options: .atomic) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + // MARK: - Markdown | |
| 53 | + | |
| 54 | + /// The task's history (prompts, steps, answers) as Markdown. | |
| 55 | + static func markdown(for task: AgentTask) -> String { | |
| 56 | + var lines: [String] = ["# \(task.title)", ""] | |
| 57 | + lines.append("Model: `\(task.modelID)` (\(task.providerID.displayName)) · Safety: \(task.safetyMode.displayName)") | |
| 58 | + if let workspace = task.workspacePath { | |
| 59 | + lines.append("Workspace: `\(workspace)`") | |
| 60 | + } | |
| 61 | + lines.append("") | |
| 62 | + for message in task.messages { | |
| 63 | + switch message.kind { | |
| 64 | + case .user: | |
| 65 | + lines.append("## 🧑 Prompt") | |
| 66 | + lines.append(message.text) | |
| 67 | + case .agentRun: | |
| 68 | + lines.append("## 🤖 Run") | |
| 69 | + for step in message.steps ?? [] { | |
| 70 | + lines.append("### Step \(step.index)") | |
| 71 | + if !step.text.isEmpty { lines.append(step.text) } | |
| 72 | + for invocation in step.toolInvocations { | |
| 73 | + lines.append("```") | |
| 74 | + lines.append("\(invocation.call.name): \(invocation.call.argumentsJSON)") | |
| 75 | + if let result = invocation.result { | |
| 76 | + lines.append("→ \(result.content)") | |
| 77 | + } | |
| 78 | + lines.append("```") | |
| 79 | + } | |
| 80 | + } | |
| 81 | + if !message.text.isEmpty { | |
| 82 | + lines.append("### Result") | |
| 83 | + lines.append(message.text) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + lines.append("") | |
| 87 | + } | |
| 88 | + return lines.joined(separator: "\n") | |
| 89 | + } | |
| 90 | + | |
| 91 | + // MARK: - PDF | |
| 92 | + | |
| 93 | + /// Renders the Markdown text into PDF data via an off-screen NSTextView. | |
| 94 | + /// Headings get a heavier system font; fenced blocks stay monospaced. | |
| 95 | + static func pdfData(from markdown: String, title: String) -> Data? { | |
| 96 | + let attributed = attributedTranscript(from: markdown) | |
| 97 | + let pageWidth: CGFloat = 612 // US Letter, points | |
| 98 | + let inset: CGFloat = 48 | |
| 99 | + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: pageWidth, height: 10)) | |
| 100 | + textView.textContainerInset = NSSize(width: inset, height: inset) | |
| 101 | + textView.isEditable = false | |
| 102 | + textView.textStorage?.setAttributedString(attributed) | |
| 103 | + guard let container = textView.textContainer, let manager = textView.layoutManager else { | |
| 104 | + return nil | |
| 105 | + } | |
| 106 | + manager.ensureLayout(for: container) | |
| 107 | + let used = manager.usedRect(for: container) | |
| 108 | + textView.frame = NSRect(x: 0, y: 0, width: pageWidth, height: used.height + inset * 2) | |
| 109 | + return textView.dataWithPDF(inside: textView.bounds) | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Simple line-based styling: #/##/### headings, ``` code fences, body. | |
| 113 | + private static func attributedTranscript(from markdown: String) -> NSAttributedString { | |
| 114 | + let result = NSMutableAttributedString() | |
| 115 | + let bodyFont = NSFont.systemFont(ofSize: 11) | |
| 116 | + let codeFont = NSFont.monospacedSystemFont(ofSize: 9.5, weight: .regular) | |
| 117 | + var inCodeFence = false | |
| 118 | + | |
| 119 | + for line in markdown.components(separatedBy: "\n") { | |
| 120 | + var font = bodyFont | |
| 121 | + var text = line | |
| 122 | + if line.hasPrefix("```") { | |
| 123 | + inCodeFence.toggle() | |
| 124 | + continue | |
| 125 | + } | |
| 126 | + if inCodeFence { | |
| 127 | + font = codeFont | |
| 128 | + } else if line.hasPrefix("### ") { | |
| 129 | + font = NSFont.systemFont(ofSize: 12, weight: .semibold) | |
| 130 | + text = String(line.dropFirst(4)) | |
| 131 | + } else if line.hasPrefix("## ") { | |
| 132 | + font = NSFont.systemFont(ofSize: 14, weight: .semibold) | |
| 133 | + text = String(line.dropFirst(3)) | |
| 134 | + } else if line.hasPrefix("# ") { | |
| 135 | + font = NSFont.systemFont(ofSize: 18, weight: .bold) | |
| 136 | + text = String(line.dropFirst(2)) | |
| 137 | + } | |
| 138 | + result.append(NSAttributedString( | |
| 139 | + string: text + "\n", | |
| 140 | + attributes: [.font: font, .foregroundColor: NSColor.textColor] | |
| 141 | + )) | |
| 142 | + } | |
| 143 | + return result | |
| 144 | + } | |
| 145 | +} | |
added
Sources/ZyquoAgent/ViewModels/AgentSettingsStore.swift
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +// | |
| 2 | +// AgentSettingsStore.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// App-wide agent preferences (Settings › Agent + Safety), persisted to | |
| 9 | +// AgentSettings.json via PersistenceService and folded into the | |
| 10 | +// AgentConfiguration each run starts with (RunController.start). | |
| 11 | +// | |
| 12 | +// Two Safety settings are persisted and surfaced but not yet enforced by | |
| 13 | +// the PolicyEngine (it has no hook for them; documented gap): | |
| 14 | +// - requireApprovalForAppleScript: the engine already asks in Manual and | |
| 15 | +// Guarded; this toggle is meant to force asking in Autonomous too. | |
| 16 | +// - workspaceEscapePolicy: the engine currently ALWAYS asks on any file | |
| 17 | +// access outside the workspace (the strict default); the "deny" choice | |
| 18 | +// is stored for a future engine hook. | |
| 19 | +// | |
| 20 | + | |
| 21 | +import Combine | |
| 22 | +import Foundation | |
| 23 | + | |
| 24 | +/// How file access outside the task workspace should be handled. | |
| 25 | +enum WorkspaceEscapePolicy: String, Codable, CaseIterable, Identifiable { | |
| 26 | + /// Every outside-workspace read/write asks for approval (built-in default). | |
| 27 | + case alwaysAsk | |
| 28 | + /// Outside-workspace access is denied without asking. | |
| 29 | + case deny | |
| 30 | + | |
| 31 | + var id: String { rawValue } | |
| 32 | + | |
| 33 | + var displayName: String { | |
| 34 | + switch self { | |
| 35 | + case .alwaysAsk: return "Always ask" | |
| 36 | + case .deny: return "Deny without asking" | |
| 37 | + } | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/// The persisted settings document (AgentSettings.json). | |
| 42 | +struct AgentSettings: Codable { | |
| 43 | + // Agent tab | |
| 44 | + var maxSteps: Int = LoopGuardConfiguration.default.maxSteps | |
| 45 | + var tokenBudget: Int = LoopGuardConfiguration.default.tokenBudget | |
| 46 | + /// Wall-clock budget in minutes. | |
| 47 | + var timeBudgetMinutes: Int = Int(LoopGuardConfiguration.default.wallClockBudget / 60) | |
| 48 | + /// Per-command timeout in seconds. | |
| 49 | + var perCommandTimeoutSeconds: Int = Int(ExecutionConfiguration.default.defaultTimeout) | |
| 50 | + var parallelToolCalls: Bool = false | |
| 51 | + /// Context-window fraction that triggers memory compaction. | |
| 52 | + var compactionThreshold: Double = MemoryConfiguration.default.compactionThreshold | |
| 53 | + | |
| 54 | + // Safety tab | |
| 55 | + var defaultSafetyMode: SafetyMode = .guarded | |
| 56 | + var requireApprovalForAppleScript: Bool = true | |
| 57 | + var workspaceEscapePolicy: WorkspaceEscapePolicy = .alwaysAsk | |
| 58 | + | |
| 59 | + // Models tab: persisted default agent model ("provider|modelID"). | |
| 60 | + var defaultAgentModelKey: String? | |
| 61 | +} | |
| 62 | + | |
| 63 | +/// Observable wrapper the Settings tabs bind to; every change persists. | |
| 64 | +@MainActor | |
| 65 | +final class AgentSettingsStore: ObservableObject { | |
| 66 | + static let fileName = "AgentSettings.json" | |
| 67 | + | |
| 68 | + @Published var settings: AgentSettings { | |
| 69 | + didSet { persistence.save(settings, to: Self.fileName) } | |
| 70 | + } | |
| 71 | + | |
| 72 | + private let persistence: PersistenceService | |
| 73 | + | |
| 74 | + init(persistence: PersistenceService = .shared) { | |
| 75 | + self.persistence = persistence | |
| 76 | + self.settings = persistence.load(AgentSettings.self, from: Self.fileName) ?? AgentSettings() | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// The engine configuration a new run starts with, built from the | |
| 80 | + /// persisted settings (clamped to sane bounds). | |
| 81 | + func agentConfiguration(personaAddendum: String? = nil) -> AgentConfiguration { | |
| 82 | + var configuration = AgentConfiguration.default | |
| 83 | + configuration.loopGuard.maxSteps = max(1, settings.maxSteps) | |
| 84 | + configuration.loopGuard.tokenBudget = max(10_000, settings.tokenBudget) | |
| 85 | + configuration.loopGuard.wallClockBudget = TimeInterval(max(1, settings.timeBudgetMinutes)) * 60 | |
| 86 | + configuration.memory.compactionThreshold = min(0.95, max(0.5, settings.compactionThreshold)) | |
| 87 | + configuration.parallelToolCalls = settings.parallelToolCalls | |
| 88 | + configuration.perCommandTimeout = TimeInterval(max(5, settings.perCommandTimeoutSeconds)) | |
| 89 | + configuration.personaAddendum = personaAddendum | |
| 90 | + return configuration | |
| 91 | + } | |
| 92 | + | |
| 93 | + /// The persisted default agent model resolved against the catalog. | |
| 94 | + func defaultAgentModel(in catalog: ModelCatalog) -> AIModel? { | |
| 95 | + if let key = settings.defaultAgentModelKey { | |
| 96 | + let parts = key.split(separator: "|", maxSplits: 1) | |
| 97 | + if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])), | |
| 98 | + let model = catalog.model(id: String(parts[1]), provider: provider) { | |
| 99 | + return model | |
| 100 | + } | |
| 101 | + } | |
| 102 | + return catalog.defaultAgentModel | |
| 103 | + } | |
| 104 | + | |
| 105 | + func setDefaultAgentModel(_ model: AIModel) { | |
| 106 | + settings.defaultAgentModelKey = "\(model.provider.rawValue)|\(model.id)" | |
| 107 | + } | |
| 108 | +} | |
added
Sources/ZyquoAgent/ViewModels/AppUIState.swift
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +// | |
| 2 | +// AppUIState.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Cross-window UI signals: the app-level command set (⌘K palette, ⌘⇧A | |
| 9 | +// audit log, template browser) publishes here and the views that own the | |
| 10 | +// corresponding local state react. Counters are used for one-shot requests | |
| 11 | +// so repeated invocations always fire onChange. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Combine | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +@MainActor | |
| 18 | +final class AppUIState: ObservableObject { | |
| 19 | + /// ⌘K command palette visibility (overlay on the main window). | |
| 20 | + @Published var showCommandPalette = false | |
| 21 | + /// Template browser sheet visibility (empty state / palette / menu). | |
| 22 | + @Published var showTemplateBrowser = false | |
| 23 | + /// One-shot request: open the activity drawer on the Audit Log tab (⌘⇧A). | |
| 24 | + @Published var auditLogRequest = 0 | |
| 25 | + /// One-shot request: toggle the activity drawer (palette action). | |
| 26 | + @Published var drawerToggleRequest = 0 | |
| 27 | + /// One-shot request: toggle the plan panel (palette action). | |
| 28 | + @Published var planToggleRequest = 0 | |
| 29 | + | |
| 30 | + func requestAuditLog() { auditLogRequest &+= 1 } | |
| 31 | + func requestDrawerToggle() { drawerToggleRequest &+= 1 } | |
| 32 | + func requestPlanToggle() { planToggleRequest &+= 1 } | |
| 33 | +} | |
added
Sources/ZyquoAgent/ViewModels/PersonaStore.swift
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +// | |
| 2 | +// PersonaStore.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Personas (Phase 6): system-prompt addition + preferred agent model + | |
| 9 | +// default safety mode, persisted to personas.json. A task adopting a | |
| 10 | +// persona gets its addendum appended to the agent system prompt on every | |
| 11 | +// run (AgentConfiguration.personaAddendum). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import Combine | |
| 15 | +import Foundation | |
| 16 | + | |
| 17 | +@MainActor | |
| 18 | +final class PersonaStore: ObservableObject { | |
| 19 | + static let fileName = "personas.json" | |
| 20 | + | |
| 21 | + @Published private(set) var personas: [Persona] { | |
| 22 | + didSet { persistence.save(personas, to: Self.fileName) } | |
| 23 | + } | |
| 24 | + | |
| 25 | + private let persistence: PersistenceService | |
| 26 | + | |
| 27 | + init(persistence: PersistenceService = .shared) { | |
| 28 | + self.persistence = persistence | |
| 29 | + self.personas = persistence.load([Persona].self, from: Self.fileName) ?? [] | |
| 30 | + } | |
| 31 | + | |
| 32 | + func persona(id: UUID?) -> Persona? { | |
| 33 | + guard let id else { return nil } | |
| 34 | + return personas.first { $0.id == id } | |
| 35 | + } | |
| 36 | + | |
| 37 | + // MARK: - CRUD | |
| 38 | + | |
| 39 | + func add(_ persona: Persona) { | |
| 40 | + personas.append(persona) | |
| 41 | + } | |
| 42 | + | |
| 43 | + func update(_ persona: Persona) { | |
| 44 | + guard let index = personas.firstIndex(where: { $0.id == persona.id }) else { return } | |
| 45 | + personas[index] = persona | |
| 46 | + } | |
| 47 | + | |
| 48 | + func delete(_ id: Persona.ID) { | |
| 49 | + personas.removeAll { $0.id == id } | |
| 50 | + } | |
| 51 | +} | |
modified
Sources/ZyquoAgent/ViewModels/RunController.swift
+74 −7
@@ -149,7 +149,7 @@ final class RunController: ObservableObject { | ||
| 149 | 149 | @Published private(set) var tokensUsed = 0 |
| 150 | 150 | @Published private(set) var stepsUsed = 0 |
| 151 | 151 | @Published private(set) var runStartedAt: Date? |
| 152 | − let loopGuardConfiguration = LoopGuardConfiguration.default | |
| 152 | + @Published private(set) var loopGuardConfiguration = LoopGuardConfiguration.default | |
| 153 | 153 | |
| 154 | 154 | // Drawer feeds |
| 155 | 155 | @Published private(set) var terminalLines: [TerminalLine] = [] |
@@ -162,20 +162,32 @@ final class RunController: ObservableObject { | ||
| 162 | 162 | let taskID: AgentTask.ID |
| 163 | 163 | private let store: TaskStore |
| 164 | 164 | private let policyPersistence: PersistenceService |
| 165 | + /// App-wide agent settings (budgets, timeouts, persona-independent | |
| 166 | + /// tunables). Nil in headless smoke tests → AgentConfiguration.default. | |
| 167 | + private weak var settings: AgentSettingsStore? | |
| 165 | 168 | |
| 166 | 169 | private var loop: AgentLoop? |
| 167 | 170 | private var policy: PolicyEngine? |
| 168 | 171 | private var audit: AuditLog? |
| 169 | 172 | private var workspace: WorkspaceManager? |
| 170 | 173 | private var consumeTask: Task<Void, Never>? |
| 174 | + /// Retained for the post-run auto-title call (app runs only). | |
| 175 | + private var titleContext: (model: AIModel, client: any ProviderClient, apiKey: String)? | |
| 176 | + private var titleTask: Task<Void, Never>? | |
| 171 | 177 | |
| 172 | 178 | /// Terminal feed cap — append-only ring so hours-long runs stay light. |
| 173 | 179 | private static let terminalLineCap = 4000 |
| 174 | 180 | |
| 175 | − init(taskID: AgentTask.ID, store: TaskStore, policyPersistence: PersistenceService = .shared) { | |
| 181 | + init( | |
| 182 | + taskID: AgentTask.ID, | |
| 183 | + store: TaskStore, | |
| 184 | + policyPersistence: PersistenceService = .shared, | |
| 185 | + settings: AgentSettingsStore? = nil | |
| 186 | + ) { | |
| 176 | 187 | self.taskID = taskID |
| 177 | 188 | self.store = store |
| 178 | 189 | self.policyPersistence = policyPersistence |
| 190 | + self.settings = settings | |
| 179 | 191 | if let workspaceURL = store.task(id: taskID)?.workspaceURL, |
| 180 | 192 | let attached = try? WorkspaceManager(existingAt: workspaceURL) { |
| 181 | 193 | self.workspace = attached |
@@ -195,6 +207,7 @@ final class RunController: ObservableObject { | ||
| 195 | 207 | func start( |
| 196 | 208 | prompt: String, |
| 197 | 209 | model: AIModel, |
| 210 | + persona: Persona? = nil, | |
| 198 | 211 | client injectedClient: (any ProviderClient)? = nil, |
| 199 | 212 | apiKey injectedKey: String? = nil |
| 200 | 213 | ) { |
@@ -253,7 +266,18 @@ final class RunController: ObservableObject { | ||
| 253 | 266 | } |
| 254 | 267 | let policy = PolicyEngine(mode: task.safetyMode, approvals: presenter, persistence: policyPersistence) |
| 255 | 268 | let audit = AuditLog(fileURL: workspace.internalDirectory.appendingPathComponent("audit.jsonl")) |
| 256 | − let executor = ExecutionService() | |
| 269 | + | |
| 270 | + // Settings › Agent budgets/tunables + the persona's prompt addendum. | |
| 271 | + let personaAddendum = persona?.systemPrompt | |
| 272 | + let configuration = settings?.agentConfiguration(personaAddendum: personaAddendum) | |
| 273 | + ?? { var c = AgentConfiguration.default; c.personaAddendum = personaAddendum; return c }() | |
| 274 | + var executionConfiguration = ExecutionConfiguration.default | |
| 275 | + if let timeout = configuration.perCommandTimeout { | |
| 276 | + executionConfiguration.defaultTimeout = timeout | |
| 277 | + } | |
| 278 | + self.loopGuardConfiguration = configuration.loopGuard | |
| 279 | + | |
| 280 | + let executor = ExecutionService(configuration: executionConfiguration) | |
| 257 | 281 | let tools = ToolRegistry.standard(executor: executor) |
| 258 | 282 | let loop = AgentLoop( |
| 259 | 283 | model: model, |
@@ -262,15 +286,20 @@ final class RunController: ObservableObject { | ||
| 262 | 286 | tools: tools, |
| 263 | 287 | policy: policy, |
| 264 | 288 | audit: audit, |
| 265 | − workspace: workspace | |
| 289 | + workspace: workspace, | |
| 290 | + configuration: configuration | |
| 266 | 291 | ) |
| 267 | 292 | self.policy = policy |
| 268 | 293 | self.audit = audit |
| 269 | 294 | self.loop = loop |
| 295 | + // Auto-title uses the injected client only for real runs — smoke | |
| 296 | + // tests inject a scripted client and must stay deterministic. | |
| 297 | + self.titleContext = injectedClient == nil ? (model, client, apiKey) : nil | |
| 270 | 298 | self.systemPromptPreview = AgentSystemPrompt.build( |
| 271 | 299 | workspacePath: workspace.root.path, |
| 272 | 300 | toolNames: tools.toolNames, |
| 273 | − safetyMode: task.safetyMode | |
| 301 | + safetyMode: task.safetyMode, | |
| 302 | + personaAddendum: personaAddendum | |
| 274 | 303 | ) |
| 275 | 304 | |
| 276 | 305 | // Reset live state. |
@@ -529,6 +558,42 @@ final class RunController: ObservableObject { | ||
| 529 | 558 | guardTrip = nil |
| 530 | 559 | refreshWorkspaceState() |
| 531 | 560 | refreshAudit() |
| 561 | + | |
| 562 | + if case .completed = outcome { | |
| 563 | + generateTitleIfStillDefault() | |
| 564 | + } | |
| 565 | + } | |
| 566 | + | |
| 567 | + // MARK: - Auto-title | |
| 568 | + | |
| 569 | + /// After a successful run, if the title is still the prompt-derived | |
| 570 | + /// default, fire a cheap background call (the task's own provider/model, | |
| 571 | + /// ≤20 tokens) to produce a 4–6 word title. Silent failure by design. | |
| 572 | + private func generateTitleIfStillDefault() { | |
| 573 | + guard titleTask == nil, let context = titleContext, let task else { return } | |
| 574 | + guard let firstPrompt = task.messages.first(where: { $0.kind == .user })?.text, | |
| 575 | + task.title == AgentTask.title(fromPrompt: firstPrompt) else { return } | |
| 576 | + let request = ChatRequest( | |
| 577 | + model: context.model, | |
| 578 | + systemPrompt: "You write concise task titles.", | |
| 579 | + messages: [Message( | |
| 580 | + role: .user, | |
| 581 | + text: "Summarize this task as a 4-6 word title. Reply with the title only — no quotes, no trailing punctuation.\n\nTask: \(firstPrompt.prefix(600))" | |
| 582 | + )], | |
| 583 | + parameters: ChatParameters(maxTokens: 20), | |
| 584 | + stream: false | |
| 585 | + ) | |
| 586 | + let taskID = taskID | |
| 587 | + let store = store | |
| 588 | + titleTask = Task { [weak self] in | |
| 589 | + defer { self?.titleTask = nil } | |
| 590 | + guard let reply = try? await context.client.complete(request, apiKey: context.apiKey) else { return } | |
| 591 | + let title = reply.text | |
| 592 | + .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 593 | + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'“”.\n")) | |
| 594 | + guard !title.isEmpty, title.count <= 80, !title.contains("\n") else { return } | |
| 595 | + store.rename(taskID, to: title) | |
| 596 | + } | |
| 532 | 597 | } |
| 533 | 598 | |
| 534 | 599 | // MARK: - Drawer refresh |
@@ -620,14 +685,16 @@ final class RunController: ObservableObject { | ||
| 620 | 685 | final class RunHub: ObservableObject { |
| 621 | 686 | private var controllers: [AgentTask.ID: RunController] = [:] |
| 622 | 687 | private let store: TaskStore |
| 688 | + private let settings: AgentSettingsStore? | |
| 623 | 689 | |
| 624 | − init(store: TaskStore) { | |
| 690 | + init(store: TaskStore, settings: AgentSettingsStore? = nil) { | |
| 625 | 691 | self.store = store |
| 692 | + self.settings = settings | |
| 626 | 693 | } |
| 627 | 694 | |
| 628 | 695 | func controller(for taskID: AgentTask.ID) -> RunController { |
| 629 | 696 | if let existing = controllers[taskID] { return existing } |
| 630 | − let controller = RunController(taskID: taskID, store: store) | |
| 697 | + let controller = RunController(taskID: taskID, store: store, settings: settings) | |
| 631 | 698 | controllers[taskID] = controller |
| 632 | 699 | return controller |
| 633 | 700 | } |
modified
Sources/ZyquoAgent/ViewModels/TaskStore.swift
+20 −2
@@ -43,19 +43,37 @@ final class TaskStore: ObservableObject { | ||
| 43 | 43 | |
| 44 | 44 | /// Creates a new task and selects it. |
| 45 | 45 | @discardableResult |
| 46 | − func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded) -> AgentTask { | |
| 47 | − let task = AgentTask( | |
| 46 | + func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded, personaID: UUID? = nil) -> AgentTask { | |
| 47 | + var task = AgentTask( | |
| 48 | 48 | title: "New Task", |
| 49 | 49 | modelID: model?.id ?? "", |
| 50 | 50 | providerID: model?.provider ?? .anthropic, |
| 51 | 51 | safetyMode: safetyMode |
| 52 | 52 | ) |
| 53 | + task.personaID = personaID | |
| 53 | 54 | tasks.insert(task, at: 0) |
| 54 | 55 | save(task) |
| 55 | 56 | selectedID = task.id |
| 56 | 57 | return task |
| 57 | 58 | } |
| 58 | 59 | |
| 60 | + /// Imports task records (Settings › Advanced). Existing IDs are skipped | |
| 61 | + /// so re-importing an export never duplicates; imported tasks arrive with | |
| 62 | + /// any active status normalized to idle. Returns how many were added. | |
| 63 | + @discardableResult | |
| 64 | + func importTasks(_ imported: [AgentTask]) -> Int { | |
| 65 | + var added = 0 | |
| 66 | + let existing = Set(tasks.map(\.id)) | |
| 67 | + for var task in imported where !existing.contains(task.id) { | |
| 68 | + if task.status.isActive { task.status = .idle } | |
| 69 | + tasks.append(task) | |
| 70 | + save(task) | |
| 71 | + added += 1 | |
| 72 | + } | |
| 73 | + tasks.sort { $0.updatedAt > $1.updatedAt } | |
| 74 | + return added | |
| 75 | + } | |
| 76 | + | |
| 59 | 77 | func task(id: AgentTask.ID) -> AgentTask? { |
| 60 | 78 | tasks.first { $0.id == id } |
| 61 | 79 | } |
added
Sources/ZyquoAgent/ViewModels/TemplateStore.swift
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +// | |
| 2 | +// TemplateStore.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Task templates: the built-in library (TaskTemplateLibrary) plus the user's | |
| 9 | +// own templates, persisted to templates.json. Built-ins are read-only. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import Combine | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class TemplateStore: ObservableObject { | |
| 17 | + static let fileName = "templates.json" | |
| 18 | + | |
| 19 | + /// User-created templates (CRUD, persisted). | |
| 20 | + @Published private(set) var userTemplates: [TaskTemplate] { | |
| 21 | + didSet { persistence.save(userTemplates, to: Self.fileName) } | |
| 22 | + } | |
| 23 | + | |
| 24 | + private let persistence: PersistenceService | |
| 25 | + | |
| 26 | + init(persistence: PersistenceService = .shared) { | |
| 27 | + self.persistence = persistence | |
| 28 | + self.userTemplates = persistence.load([TaskTemplate].self, from: Self.fileName) ?? [] | |
| 29 | + } | |
| 30 | + | |
| 31 | + /// Built-ins first (library order), then user templates. | |
| 32 | + var all: [TaskTemplate] { | |
| 33 | + TaskTemplateLibrary.builtIn + userTemplates | |
| 34 | + } | |
| 35 | + | |
| 36 | + func templates(in category: TemplateCategory) -> [TaskTemplate] { | |
| 37 | + all.filter { $0.category == category } | |
| 38 | + } | |
| 39 | + | |
| 40 | + // MARK: - CRUD (user templates only) | |
| 41 | + | |
| 42 | + func add(_ template: TaskTemplate) { | |
| 43 | + var template = template | |
| 44 | + template.isBuiltIn = false | |
| 45 | + userTemplates.append(template) | |
| 46 | + } | |
| 47 | + | |
| 48 | + func update(_ template: TaskTemplate) { | |
| 49 | + guard let index = userTemplates.firstIndex(where: { $0.id == template.id }) else { return } | |
| 50 | + userTemplates[index] = template | |
| 51 | + } | |
| 52 | + | |
| 53 | + func delete(_ id: TaskTemplate.ID) { | |
| 54 | + userTemplates.removeAll { $0.id == id } | |
| 55 | + } | |
| 56 | +} | |
added
Sources/ZyquoAgent/Views/CommandPaletteView.swift
+187 −0
@@ -0,0 +1,187 @@ | ||
| 1 | +// | |
| 2 | +// CommandPaletteView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The ⌘K command palette: one fuzzy-filtered list over app actions | |
| 9 | +// (New Task, Open Settings, toggle panels, audit log, template browser), | |
| 10 | +// the task library, and every task template. Enter runs the top hit, | |
| 11 | +// Esc dismisses. Rendered as an overlay card on the main window. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +/// One palette entry. | |
| 17 | +struct PaletteItem: Identifiable { | |
| 18 | + enum Kind { | |
| 19 | + case action | |
| 20 | + case template | |
| 21 | + case task | |
| 22 | + } | |
| 23 | + | |
| 24 | + let id: String | |
| 25 | + var kind: Kind | |
| 26 | + var title: String | |
| 27 | + var subtitle: String? | |
| 28 | + var symbolName: String | |
| 29 | + var perform: () -> Void | |
| 30 | +} | |
| 31 | + | |
| 32 | +struct CommandPaletteView: View { | |
| 33 | + var items: [PaletteItem] | |
| 34 | + var onDismiss: () -> Void | |
| 35 | + | |
| 36 | + @State private var query = "" | |
| 37 | + @FocusState private var focused: Bool | |
| 38 | + | |
| 39 | + private var filtered: [PaletteItem] { | |
| 40 | + let trimmed = query.trimmingCharacters(in: .whitespaces) | |
| 41 | + guard !trimmed.isEmpty else { return items } | |
| 42 | + return items | |
| 43 | + .compactMap { item -> (PaletteItem, Int)? in | |
| 44 | + guard let score = Self.fuzzyScore(needle: trimmed, haystack: item.title) else { | |
| 45 | + if let subtitle = item.subtitle, | |
| 46 | + let subScore = Self.fuzzyScore(needle: trimmed, haystack: subtitle) { | |
| 47 | + return (item, subScore + 100) | |
| 48 | + } | |
| 49 | + return nil | |
| 50 | + } | |
| 51 | + return (item, score) | |
| 52 | + } | |
| 53 | + .sorted { $0.1 < $1.1 } | |
| 54 | + .map(\.0) | |
| 55 | + } | |
| 56 | + | |
| 57 | + var body: some View { | |
| 58 | + VStack(spacing: 0) { | |
| 59 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 60 | + Image(systemName: "command") | |
| 61 | + .font(.system(size: 13)) | |
| 62 | + .foregroundStyle(ZyquoColor.accent) | |
| 63 | + TextField("Type a command, template, or task…", text: $query) | |
| 64 | + .textFieldStyle(.plain) | |
| 65 | + .font(ZyquoFont.body(size: 15)) | |
| 66 | + .focused($focused) | |
| 67 | + .onSubmit(runFirst) | |
| 68 | + Text("esc") | |
| 69 | + .font(ZyquoFont.code(size: 10)) | |
| 70 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 71 | + .padding(.horizontal, ZyquoSpacing.xxs) | |
| 72 | + .padding(.vertical, 1) | |
| 73 | + .background( | |
| 74 | + RoundedRectangle(cornerRadius: 3, style: .continuous) | |
| 75 | + .fill(ZyquoColor.surfaceSecondary) | |
| 76 | + ) | |
| 77 | + } | |
| 78 | + .padding(ZyquoSpacing.sm) | |
| 79 | + ZyquoHairline() | |
| 80 | + ScrollView { | |
| 81 | + LazyVStack(spacing: 1) { | |
| 82 | + if filtered.isEmpty { | |
| 83 | + Text("No matches") | |
| 84 | + .font(ZyquoFont.body(size: 12.5)) | |
| 85 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 86 | + .padding(ZyquoSpacing.sm) | |
| 87 | + } | |
| 88 | + ForEach(Array(filtered.prefix(40).enumerated()), id: \.element.id) { index, item in | |
| 89 | + row(item, isFirst: index == 0) | |
| 90 | + } | |
| 91 | + } | |
| 92 | + .padding(ZyquoSpacing.xxs) | |
| 93 | + } | |
| 94 | + .frame(maxHeight: 320) | |
| 95 | + } | |
| 96 | + .frame(width: 560) | |
| 97 | + .background( | |
| 98 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 99 | + .fill(ZyquoColor.surface) | |
| 100 | + ) | |
| 101 | + .overlay( | |
| 102 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 103 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 104 | + ) | |
| 105 | + .zyquoSoftShadow() | |
| 106 | + .onAppear { focused = true } | |
| 107 | + .onExitCommand { onDismiss() } | |
| 108 | + } | |
| 109 | + | |
| 110 | + private func row(_ item: PaletteItem, isFirst: Bool) -> some View { | |
| 111 | + Button { | |
| 112 | + onDismiss() | |
| 113 | + item.perform() | |
| 114 | + } label: { | |
| 115 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 116 | + Image(systemName: item.symbolName) | |
| 117 | + .font(.system(size: 12)) | |
| 118 | + .foregroundStyle(ZyquoColor.accent) | |
| 119 | + .frame(width: 18) | |
| 120 | + Text(item.title) | |
| 121 | + .font(ZyquoFont.body(size: 13)) | |
| 122 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 123 | + .lineLimit(1) | |
| 124 | + if let subtitle = item.subtitle { | |
| 125 | + Text(subtitle) | |
| 126 | + .font(ZyquoFont.caption) | |
| 127 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 128 | + .lineLimit(1) | |
| 129 | + } | |
| 130 | + Spacer(minLength: 0) | |
| 131 | + ZyquoBadge(text: kindLabel(item.kind)) | |
| 132 | + if isFirst && !query.isEmpty { | |
| 133 | + Text("↩") | |
| 134 | + .font(ZyquoFont.code(size: 10)) | |
| 135 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 136 | + } | |
| 137 | + } | |
| 138 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 139 | + .padding(.vertical, 5) | |
| 140 | + .background( | |
| 141 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 142 | + .fill(isFirst && !query.isEmpty ? ZyquoColor.accentSubtle : .clear) | |
| 143 | + ) | |
| 144 | + .contentShape(Rectangle()) | |
| 145 | + } | |
| 146 | + .buttonStyle(.plain) | |
| 147 | + .zyquoHoverHighlight() | |
| 148 | + } | |
| 149 | + | |
| 150 | + private func kindLabel(_ kind: PaletteItem.Kind) -> String { | |
| 151 | + switch kind { | |
| 152 | + case .action: return "action" | |
| 153 | + case .template: return "template" | |
| 154 | + case .task: return "task" | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + private func runFirst() { | |
| 159 | + guard let first = filtered.first else { return } | |
| 160 | + onDismiss() | |
| 161 | + first.perform() | |
| 162 | + } | |
| 163 | + | |
| 164 | + /// Case-insensitive subsequence match; lower score = tighter match | |
| 165 | + /// (prefix matches beat scattered ones). Nil = no match. | |
| 166 | + static func fuzzyScore(needle: String, haystack: String) -> Int? { | |
| 167 | + let needleChars = Array(needle.lowercased()) | |
| 168 | + let haystackChars = Array(haystack.lowercased()) | |
| 169 | + guard !needleChars.isEmpty else { return 0 } | |
| 170 | + var score = 0 | |
| 171 | + var haystackIndex = 0 | |
| 172 | + for character in needleChars { | |
| 173 | + var found = false | |
| 174 | + while haystackIndex < haystackChars.count { | |
| 175 | + if haystackChars[haystackIndex] == character { | |
| 176 | + found = true | |
| 177 | + haystackIndex += 1 | |
| 178 | + break | |
| 179 | + } | |
| 180 | + score += 1 | |
| 181 | + haystackIndex += 1 | |
| 182 | + } | |
| 183 | + if !found { return nil } | |
| 184 | + } | |
| 185 | + return score | |
| 186 | + } | |
| 187 | +} | |
modified
Sources/ZyquoAgent/Views/EmptyStateView.swift
+59 −0
@@ -15,10 +15,17 @@ import SwiftUI | ||
| 15 | 15 | struct AgentEmptyStateView: View { |
| 16 | 16 | let model: AIModel? |
| 17 | 17 | let safetyMode: SafetyMode |
| 18 | + /// Name of the task's active persona (nil = none / no task yet). | |
| 19 | + var personaName: String? | |
| 18 | 20 | var onSelectModel: (AIModel) -> Void |
| 19 | 21 | var onSelectSafetyMode: (SafetyMode) -> Void |
| 22 | + /// Called with the chosen persona (nil = none). | |
| 23 | + var onSelectPersona: ((Persona?) -> Void)? | |
| 24 | + var onBrowseTemplates: (() -> Void)? | |
| 20 | 25 | var onSuggestion: (String) -> Void |
| 21 | 26 | |
| 27 | + @EnvironmentObject private var personas: PersonaStore | |
| 28 | + | |
| 22 | 29 | private static let suggestions: [(symbol: String, title: String, prompt: String)] = [ |
| 23 | 30 | ("folder.badge.gearshape", "Organize my Downloads folder", |
| 24 | 31 | "Look at my Downloads folder, group the files by type into subfolders (Images, Documents, Archives, Installers…), and show me a summary of what you moved."), |
@@ -46,6 +53,24 @@ struct AgentEmptyStateView: View { | ||
| 46 | 53 | HStack(spacing: ZyquoSpacing.sm) { |
| 47 | 54 | ModelChipView(model: model, onSelect: onSelectModel) |
| 48 | 55 | SafetyModePicker(mode: safetyMode, onSelect: onSelectSafetyMode) |
| 56 | + if let onSelectPersona, !personas.personas.isEmpty { | |
| 57 | + personaMenu(onSelectPersona) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + if let onBrowseTemplates { | |
| 61 | + Button { | |
| 62 | + onBrowseTemplates() | |
| 63 | + } label: { | |
| 64 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 65 | + Image(systemName: "square.grid.2x2") | |
| 66 | + .font(.system(size: 11)) | |
| 67 | + Text("Browse templates") | |
| 68 | + .font(ZyquoFont.body(size: 12.5)) | |
| 69 | + } | |
| 70 | + .foregroundStyle(ZyquoColor.accent) | |
| 71 | + } | |
| 72 | + .buttonStyle(.plain) | |
| 73 | + .help("25+ ready-made agent tasks (⌘K)") | |
| 49 | 74 | } |
| 50 | 75 | LazyVGrid( |
| 51 | 76 | columns: [GridItem(.flexible()), GridItem(.flexible())], |
@@ -87,6 +112,40 @@ struct AgentEmptyStateView: View { | ||
| 87 | 112 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 88 | 113 | .background(ZyquoColor.background) |
| 89 | 114 | } |
| 115 | + | |
| 116 | + /// Persona chip + menu (None + every stored persona). | |
| 117 | + private func personaMenu(_ onSelect: @escaping (Persona?) -> Void) -> some View { | |
| 118 | + Menu { | |
| 119 | + Button("None") { onSelect(nil) } | |
| 120 | + Divider() | |
| 121 | + ForEach(personas.personas) { persona in | |
| 122 | + Button { | |
| 123 | + onSelect(persona) | |
| 124 | + } label: { | |
| 125 | + Label(persona.name, systemImage: persona.symbolName) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + } label: { | |
| 129 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 130 | + Image(systemName: "person.crop.circle") | |
| 131 | + .font(.system(size: 11, weight: .medium)) | |
| 132 | + .foregroundStyle(ZyquoColor.accent) | |
| 133 | + Text(personaName ?? "Persona") | |
| 134 | + .font(ZyquoFont.bodyEmphasis(size: 12.5)) | |
| 135 | + .foregroundStyle(personaName == nil ? ZyquoColor.textSecondary : ZyquoColor.textPrimary) | |
| 136 | + .lineLimit(1) | |
| 137 | + } | |
| 138 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 139 | + .padding(.vertical, 4) | |
| 140 | + .background( | |
| 141 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 142 | + .fill(ZyquoColor.surfaceSecondary) | |
| 143 | + ) | |
| 144 | + } | |
| 145 | + .menuStyle(.borderlessButton) | |
| 146 | + .fixedSize() | |
| 147 | + .help("Persona — adds a system-prompt section and can pin a model and safety default") | |
| 148 | + } | |
| 90 | 149 | } |
| 91 | 150 | |
| 92 | 151 | /// The Manual / Guarded / Autonomous segmented control (header + empty state). |
modified
Sources/ZyquoAgent/Views/MainWindowView.swift
+138 −26
@@ -7,7 +7,9 @@ | ||
| 7 | 7 | // |
| 8 | 8 | // The command-center root: translucent 260pt sidebar (NavigationSplitView's |
| 9 | 9 | // native sidebar material) + the task detail. With no task selected, the |
| 10 | −// "What should I do on your Mac?" hero creates one. | |
| 10 | +// "What should I do on your Mac?" hero creates one. Hosts the ⌘K command | |
| 11 | +// palette overlay and the template browser sheet; the root re-identifies on | |
| 12 | +// accent change so every ZyquoColor.accent token redraws immediately. | |
| 11 | 13 | // |
| 12 | 14 | |
| 13 | 15 | import SwiftUI |
@@ -16,40 +18,150 @@ struct MainWindowView: View { | ||
| 16 | 18 | @EnvironmentObject private var store: TaskStore |
| 17 | 19 | @EnvironmentObject private var catalog: ModelCatalog |
| 18 | 20 | @EnvironmentObject private var appearance: AppearanceStore |
| 21 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 22 | + @EnvironmentObject private var templates: TemplateStore | |
| 23 | + @EnvironmentObject private var personas: PersonaStore | |
| 24 | + @EnvironmentObject private var uiState: AppUIState | |
| 19 | 25 | |
| 20 | 26 | var body: some View { |
| 21 | − NavigationSplitView { | |
| 22 | − SidebarView() | |
| 23 | − .navigationSplitViewColumnWidth( | |
| 24 | − min: ZyquoMetrics.sidebarWidth, | |
| 25 | − ideal: ZyquoMetrics.sidebarWidth, | |
| 26 | − max: 360 | |
| 27 | − ) | |
| 28 | − } detail: { | |
| 29 | − if let id = store.selectedID, store.task(id: id) != nil { | |
| 30 | − TaskDetailView(taskID: id) | |
| 31 | − } else { | |
| 32 | − AgentEmptyStateView( | |
| 33 | − model: catalog.defaultAgentModel, | |
| 34 | − safetyMode: .guarded, | |
| 35 | − onSelectModel: { model in | |
| 36 | − store.newTask(model: model) | |
| 37 | − }, | |
| 38 | − onSelectSafetyMode: { mode in | |
| 39 | − store.newTask(model: catalog.defaultAgentModel, safetyMode: mode) | |
| 40 | − }, | |
| 41 | − onSuggestion: { prompt in | |
| 42 | − store.pendingDraft = prompt | |
| 43 | − store.newTask(model: catalog.defaultAgentModel) | |
| 44 | − } | |
| 45 | − ) | |
| 27 | + ZStack(alignment: .top) { | |
| 28 | + NavigationSplitView { | |
| 29 | + SidebarView() | |
| 30 | + .navigationSplitViewColumnWidth( | |
| 31 | + min: ZyquoMetrics.sidebarWidth, | |
| 32 | + ideal: ZyquoMetrics.sidebarWidth, | |
| 33 | + max: 360 | |
| 34 | + ) | |
| 35 | + } detail: { | |
| 36 | + if let id = store.selectedID, store.task(id: id) != nil { | |
| 37 | + TaskDetailView(taskID: id) | |
| 38 | + } else { | |
| 39 | + AgentEmptyStateView( | |
| 40 | + model: defaultModel, | |
| 41 | + safetyMode: settings.settings.defaultSafetyMode, | |
| 42 | + personaName: nil, | |
| 43 | + onSelectModel: { model in | |
| 44 | + store.newTask(model: model, safetyMode: settings.settings.defaultSafetyMode) | |
| 45 | + }, | |
| 46 | + onSelectSafetyMode: { mode in | |
| 47 | + store.newTask(model: defaultModel, safetyMode: mode) | |
| 48 | + }, | |
| 49 | + onSelectPersona: { persona in | |
| 50 | + newTask(with: persona) | |
| 51 | + }, | |
| 52 | + onBrowseTemplates: { | |
| 53 | + uiState.showTemplateBrowser = true | |
| 54 | + }, | |
| 55 | + onSuggestion: { prompt in | |
| 56 | + store.pendingDraft = prompt | |
| 57 | + store.newTask(model: defaultModel, safetyMode: settings.settings.defaultSafetyMode) | |
| 58 | + } | |
| 59 | + ) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + if uiState.showCommandPalette { | |
| 63 | + paletteOverlay | |
| 46 | 64 | } |
| 47 | 65 | } |
| 48 | 66 | .frame( |
| 49 | 67 | minWidth: ZyquoMetrics.windowMinWidth, |
| 50 | 68 | minHeight: ZyquoMetrics.windowMinHeight |
| 51 | 69 | ) |
| 70 | + .sheet(isPresented: $uiState.showTemplateBrowser) { | |
| 71 | + TemplateBrowserView { prompt, safetyMode in | |
| 72 | + store.pendingDraft = prompt | |
| 73 | + store.newTask(model: defaultModel, safetyMode: safetyMode) | |
| 74 | + } | |
| 75 | + } | |
| 52 | 76 | .preferredColorScheme(appearance.themeMode.colorScheme) |
| 53 | 77 | .tint(appearance.accentColor) |
| 78 | + .id(appearance.accent) | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// New tasks start with the persisted default agent model. | |
| 82 | + private var defaultModel: AIModel? { | |
| 83 | + settings.defaultAgentModel(in: catalog) | |
| 84 | + } | |
| 85 | + | |
| 86 | + private func newTask(with persona: Persona?) { | |
| 87 | + let model: AIModel? | |
| 88 | + if let persona, let id = persona.modelID, let provider = persona.provider, | |
| 89 | + let preferred = catalog.model(id: id, provider: provider) { | |
| 90 | + model = preferred | |
| 91 | + } else { | |
| 92 | + model = defaultModel | |
| 93 | + } | |
| 94 | + store.newTask( | |
| 95 | + model: model, | |
| 96 | + safetyMode: persona?.safetyMode ?? settings.settings.defaultSafetyMode, | |
| 97 | + personaID: persona?.id | |
| 98 | + ) | |
| 99 | + } | |
| 100 | + | |
| 101 | + // MARK: - ⌘K palette | |
| 102 | + | |
| 103 | + private var paletteOverlay: some View { | |
| 104 | + ZStack(alignment: .top) { | |
| 105 | + // Click-away scrim (transparent, keeps the window visible). | |
| 106 | + Color.black.opacity(0.001) | |
| 107 | + .onTapGesture { uiState.showCommandPalette = false } | |
| 108 | + CommandPaletteView(items: paletteItems) { | |
| 109 | + uiState.showCommandPalette = false | |
| 110 | + } | |
| 111 | + .padding(.top, ZyquoSpacing.xxl * 2) | |
| 112 | + } | |
| 113 | + .ignoresSafeArea() | |
| 114 | + .transition(.opacity) | |
| 115 | + } | |
| 116 | + | |
| 117 | + private var paletteItems: [PaletteItem] { | |
| 118 | + var items: [PaletteItem] = [ | |
| 119 | + PaletteItem(id: "action-new-task", kind: .action, title: "New Task", subtitle: "⌘N", symbolName: "plus.circle") { | |
| 120 | + store.newTask(model: defaultModel, safetyMode: settings.settings.defaultSafetyMode) | |
| 121 | + }, | |
| 122 | + PaletteItem(id: "action-settings", kind: .action, title: "Open Settings", subtitle: "⌘,", symbolName: "gearshape") { | |
| 123 | + SettingsOpener.open() | |
| 124 | + }, | |
| 125 | + PaletteItem(id: "action-templates", kind: .action, title: "Browse Templates", subtitle: nil, symbolName: "square.grid.2x2") { | |
| 126 | + uiState.showTemplateBrowser = true | |
| 127 | + }, | |
| 128 | + PaletteItem(id: "action-audit", kind: .action, title: "Open Audit Log", subtitle: "⌘⇧A", symbolName: "list.bullet.rectangle") { | |
| 129 | + uiState.requestAuditLog() | |
| 130 | + }, | |
| 131 | + PaletteItem(id: "action-drawer", kind: .action, title: "Toggle Activity Drawer", subtitle: nil, symbolName: "terminal") { | |
| 132 | + uiState.requestDrawerToggle() | |
| 133 | + }, | |
| 134 | + PaletteItem(id: "action-plan", kind: .action, title: "Toggle Plan Panel", subtitle: nil, symbolName: "sidebar.right") { | |
| 135 | + uiState.requestPlanToggle() | |
| 136 | + }, | |
| 137 | + ] | |
| 138 | + for template in templates.all { | |
| 139 | + items.append(PaletteItem( | |
| 140 | + id: "template-\(template.id.uuidString)", | |
| 141 | + kind: .template, | |
| 142 | + title: template.title, | |
| 143 | + subtitle: template.category.displayName, | |
| 144 | + symbolName: template.displaySymbol | |
| 145 | + ) { | |
| 146 | + if template.variables.isEmpty { | |
| 147 | + store.pendingDraft = template.prompt | |
| 148 | + store.newTask(model: defaultModel, safetyMode: template.suggestedSafetyMode) | |
| 149 | + } else { | |
| 150 | + uiState.showTemplateBrowser = true | |
| 151 | + } | |
| 152 | + }) | |
| 153 | + } | |
| 154 | + for task in store.tasks.prefix(60) { | |
| 155 | + items.append(PaletteItem( | |
| 156 | + id: "task-\(task.id.uuidString)", | |
| 157 | + kind: .task, | |
| 158 | + title: task.title, | |
| 159 | + subtitle: task.status.displayName, | |
| 160 | + symbolName: "checklist" | |
| 161 | + ) { | |
| 162 | + store.selectedID = task.id | |
| 163 | + }) | |
| 164 | + } | |
| 165 | + return items | |
| 54 | 166 | } |
| 55 | 167 | } |
modified
Sources/ZyquoAgent/Views/ModelChipView.swift
+4 −2
@@ -67,6 +67,7 @@ struct ModelPickerView: View { | ||
| 67 | 67 | |
| 68 | 68 | @EnvironmentObject private var catalog: ModelCatalog |
| 69 | 69 | @EnvironmentObject private var vault: KeyVaultStore |
| 70 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 70 | 71 | @State private var query = "" |
| 71 | 72 | |
| 72 | 73 | var body: some View { |
@@ -130,8 +131,9 @@ struct ModelPickerView: View { | ||
| 130 | 131 | } |
| 131 | 132 | |
| 132 | 133 | private func row(_ model: AIModel) -> some View { |
| 133 | − let isDefault = model.id == catalog.defaultAgentModel?.id | |
| 134 | − && model.provider == catalog.defaultAgentModel?.provider | |
| 134 | + let defaultModel = settings.defaultAgentModel(in: catalog) | |
| 135 | + let isDefault = model.id == defaultModel?.id | |
| 136 | + && model.provider == defaultModel?.provider | |
| 135 | 137 | let isSelected = model.id == selected?.id && model.provider == selected?.provider |
| 136 | 138 | return Button { |
| 137 | 139 | onSelect(model) |
added
Sources/ZyquoAgent/Views/QuickTask/QuickTaskPanel.swift
+301 −0
@@ -0,0 +1,301 @@ | ||
| 1 | +// | |
| 2 | +// QuickTaskPanel.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Global Quick Task (⌥Space): floating Spotlight-style panel — 640pt wide, | |
| 9 | +// radius 14, soft shadow — that fires a one-off agent task with the default | |
| 10 | +// model in Guarded mode, expands to show the live steps compactly (reusing | |
| 11 | +// StepCardView, including inline approval cards), and can promote the task | |
| 12 | +// to the full window. ESC dismisses. Ported from Zyquo Cloud's QuickChat | |
| 13 | +// controller pattern (NSPanel + ⌥Space global/local key monitors). | |
| 14 | +// | |
| 15 | + | |
| 16 | +import AppKit | |
| 17 | +import SwiftUI | |
| 18 | + | |
| 19 | +/// Manages the floating NSPanel hosting QuickTaskView and the global hotkey. | |
| 20 | +@MainActor | |
| 21 | +final class QuickTaskController { | |
| 22 | + private var panel: NSPanel? | |
| 23 | + private var hotKeyMonitor: Any? | |
| 24 | + private let environment: AppEnvironment | |
| 25 | + | |
| 26 | + init(environment: AppEnvironment) { | |
| 27 | + self.environment = environment | |
| 28 | + installHotKey() | |
| 29 | + } | |
| 30 | + | |
| 31 | + private func installHotKey() { | |
| 32 | + // ⌥Space, global. The global monitor fires while other apps are | |
| 33 | + // frontmost; the local monitor covers Zyquo Agent itself. | |
| 34 | + hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in | |
| 35 | + guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return } | |
| 36 | + Task { @MainActor in self?.toggle() } | |
| 37 | + } | |
| 38 | + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in | |
| 39 | + if event.keyCode == 49, event.modifierFlags.contains(.option) { | |
| 40 | + Task { @MainActor in self?.toggle() } | |
| 41 | + return nil | |
| 42 | + } | |
| 43 | + return event | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + func toggle() { | |
| 48 | + if let panel, panel.isVisible { | |
| 49 | + panel.orderOut(nil) | |
| 50 | + return | |
| 51 | + } | |
| 52 | + show() | |
| 53 | + } | |
| 54 | + | |
| 55 | + func show() { | |
| 56 | + let panel = self.panel ?? makePanel() | |
| 57 | + self.panel = panel | |
| 58 | + positionOnActiveScreen(panel) | |
| 59 | + panel.makeKeyAndOrderFront(nil) | |
| 60 | + NSApp.activate(ignoringOtherApps: true) | |
| 61 | + } | |
| 62 | + | |
| 63 | + private func makePanel() -> NSPanel { | |
| 64 | + let hosting = NSHostingView( | |
| 65 | + rootView: QuickTaskView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) }) | |
| 66 | + .environmentObject(environment.tasks) | |
| 67 | + .environmentObject(environment.hub) | |
| 68 | + .environmentObject(environment.catalog) | |
| 69 | + .environmentObject(environment.vault) | |
| 70 | + .environmentObject(environment.appearance) | |
| 71 | + .environmentObject(environment.settings) | |
| 72 | + ) | |
| 73 | + let panel = KeyableTaskPanel( | |
| 74 | + contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickTaskWidth, height: 120), | |
| 75 | + styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled], | |
| 76 | + backing: .buffered, | |
| 77 | + defer: false | |
| 78 | + ) | |
| 79 | + panel.titleVisibility = .hidden | |
| 80 | + panel.titlebarAppearsTransparent = true | |
| 81 | + panel.isMovableByWindowBackground = true | |
| 82 | + panel.level = .floating | |
| 83 | + panel.collectionBehavior = [.canJoinAllSpaces, .transient] | |
| 84 | + panel.isOpaque = false | |
| 85 | + panel.backgroundColor = .clear | |
| 86 | + panel.hidesOnDeactivate = false | |
| 87 | + panel.contentView = hosting | |
| 88 | + return panel | |
| 89 | + } | |
| 90 | + | |
| 91 | + private func positionOnActiveScreen(_ panel: NSPanel) { | |
| 92 | + let screen = NSScreen.main ?? NSScreen.screens[0] | |
| 93 | + let frame = screen.visibleFrame | |
| 94 | + let size = panel.frame.size | |
| 95 | + let x = frame.midX - size.width / 2 | |
| 96 | + let y = frame.maxY - frame.height * 0.30 - size.height | |
| 97 | + panel.setFrameOrigin(NSPoint(x: x, y: y)) | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +/// NSPanel subclass that can become key despite .nonactivatingPanel. | |
| 102 | +final class KeyableTaskPanel: NSPanel { | |
| 103 | + override var canBecomeKey: Bool { true } | |
| 104 | + override func cancelOperation(_ sender: Any?) { | |
| 105 | + orderOut(nil) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +// MARK: - View | |
| 110 | + | |
| 111 | +struct QuickTaskView: View { | |
| 112 | + var onDismiss: () -> Void | |
| 113 | + | |
| 114 | + @EnvironmentObject private var tasks: TaskStore | |
| 115 | + @EnvironmentObject private var hub: RunHub | |
| 116 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 117 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 118 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 119 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 120 | + | |
| 121 | + @State private var input = "" | |
| 122 | + @State private var model: AIModel? | |
| 123 | + @State private var controller: RunController? | |
| 124 | + @State private var errorText: String? | |
| 125 | + @FocusState private var focused: Bool | |
| 126 | + | |
| 127 | + var body: some View { | |
| 128 | + VStack(spacing: 0) { | |
| 129 | + inputRow | |
| 130 | + if let controller { | |
| 131 | + ZyquoHairline() | |
| 132 | + QuickTaskRunView(controller: controller, fontSize: 12.5) | |
| 133 | + footer(controller) | |
| 134 | + } else if let errorText { | |
| 135 | + ZyquoHairline() | |
| 136 | + Text(errorText) | |
| 137 | + .font(ZyquoFont.body(size: 12.5)) | |
| 138 | + .foregroundStyle(ZyquoColor.danger) | |
| 139 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 140 | + .padding(ZyquoSpacing.md) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + .frame(width: ZyquoMetrics.quickTaskWidth) | |
| 144 | + .background( | |
| 145 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 146 | + .fill(ZyquoColor.surface) | |
| 147 | + ) | |
| 148 | + .overlay( | |
| 149 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 150 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 151 | + ) | |
| 152 | + .zyquoSoftShadow() | |
| 153 | + .tint(appearance.accentColor) | |
| 154 | + .onAppear { focused = true } | |
| 155 | + .onExitCommand { onDismiss() } | |
| 156 | + } | |
| 157 | + | |
| 158 | + private var inputRow: some View { | |
| 159 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 160 | + AgentZGlyph(size: 24) | |
| 161 | + TextField("What should I do on your Mac?", text: $input) | |
| 162 | + .textFieldStyle(.plain) | |
| 163 | + .font(ZyquoFont.body(size: 16)) | |
| 164 | + .focused($focused) | |
| 165 | + .onSubmit(run) | |
| 166 | + ZyquoBadge(text: SafetyMode.guarded.displayName, color: ZyquoColor.textSecondary) | |
| 167 | + .help("Quick tasks run in Guarded mode: safe actions auto-run, anything mutating asks") | |
| 168 | + ModelChipView(model: model ?? settings.defaultAgentModel(in: catalog)) { chosen in | |
| 169 | + model = chosen | |
| 170 | + } | |
| 171 | + if controller?.isRunning == true { | |
| 172 | + Button { | |
| 173 | + controller?.cancel() | |
| 174 | + } label: { | |
| 175 | + Image(systemName: "stop.fill") | |
| 176 | + .foregroundStyle(ZyquoColor.danger) | |
| 177 | + } | |
| 178 | + .buttonStyle(.plain) | |
| 179 | + .help("Stop the run") | |
| 180 | + } | |
| 181 | + } | |
| 182 | + .padding(ZyquoSpacing.md) | |
| 183 | + } | |
| 184 | + | |
| 185 | + private func footer(_ controller: RunController) -> some View { | |
| 186 | + HStack { | |
| 187 | + if controller.isRunning { | |
| 188 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 189 | + ProgressView().controlSize(.mini) | |
| 190 | + Text("Running…") | |
| 191 | + .font(ZyquoFont.caption) | |
| 192 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 193 | + } | |
| 194 | + } | |
| 195 | + Spacer() | |
| 196 | + Button("Open in Zyquo Agent") { promote(controller) } | |
| 197 | + .controlSize(.small) | |
| 198 | + } | |
| 199 | + .padding(.horizontal, ZyquoSpacing.md) | |
| 200 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 201 | + } | |
| 202 | + | |
| 203 | + // MARK: - Actions | |
| 204 | + | |
| 205 | + private func run() { | |
| 206 | + let prompt = input.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 207 | + guard !prompt.isEmpty, controller?.isRunning != true else { return } | |
| 208 | + guard let target = model ?? settings.defaultAgentModel(in: catalog) else { | |
| 209 | + errorText = "No agent-capable model available — configure one in Settings › Models." | |
| 210 | + return | |
| 211 | + } | |
| 212 | + guard AgentCLI.resolveAPIKey(for: target.provider) != nil else { | |
| 213 | + errorText = ProviderError.missingAPIKey(target.provider).localizedDescription | |
| 214 | + return | |
| 215 | + } | |
| 216 | + errorText = nil | |
| 217 | + let task = tasks.newTask(model: target, safetyMode: .guarded) | |
| 218 | + let runController = hub.controller(for: task.id) | |
| 219 | + controller = runController | |
| 220 | + runController.start(prompt: prompt, model: target) | |
| 221 | + } | |
| 222 | + | |
| 223 | + /// Brings the task into the full command-center window. | |
| 224 | + private func promote(_ controller: RunController) { | |
| 225 | + tasks.selectedID = controller.taskID | |
| 226 | + onDismiss() | |
| 227 | + NSApp.activate(ignoringOtherApps: true) | |
| 228 | + } | |
| 229 | +} | |
| 230 | + | |
| 231 | +/// The compact live run feed: step cards + inline approval + outcome. | |
| 232 | +private struct QuickTaskRunView: View { | |
| 233 | + @ObservedObject var controller: RunController | |
| 234 | + let fontSize: Double | |
| 235 | + | |
| 236 | + private static let bottomID = "quicktask-bottom" | |
| 237 | + | |
| 238 | + var body: some View { | |
| 239 | + ScrollViewReader { proxy in | |
| 240 | + ScrollView { | |
| 241 | + LazyVStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 242 | + ForEach(controller.entries) { entry in | |
| 243 | + switch entry { | |
| 244 | + case .step(let step): | |
| 245 | + StepCardView(step: step, fontSize: fontSize, isLive: controller.isRunning) | |
| 246 | + case .compaction(let record): | |
| 247 | + CompactionNoticeView(record: record) | |
| 248 | + } | |
| 249 | + } | |
| 250 | + if let approval = controller.pendingApproval { | |
| 251 | + ApprovalCardView(approval: approval) { resolution in | |
| 252 | + controller.resolveApproval(resolution) | |
| 253 | + } | |
| 254 | + } | |
| 255 | + if let trip = controller.guardTrip { | |
| 256 | + GuardTripCardView( | |
| 257 | + trip: trip, | |
| 258 | + onContinue: { controller.resumeAfterTrip(raisingBudget: true) }, | |
| 259 | + onStop: { controller.stopAfterTrip() } | |
| 260 | + ) | |
| 261 | + } | |
| 262 | + outcomeView | |
| 263 | + if let error = controller.lastError { | |
| 264 | + RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger) | |
| 265 | + } | |
| 266 | + Color.clear.frame(height: 1).id(Self.bottomID) | |
| 267 | + } | |
| 268 | + .padding(ZyquoSpacing.md) | |
| 269 | + } | |
| 270 | + .frame(maxHeight: 380) | |
| 271 | + .onChange(of: fingerprint) { _ in | |
| 272 | + proxy.scrollTo(Self.bottomID, anchor: .bottom) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + @ViewBuilder | |
| 278 | + private var outcomeView: some View { | |
| 279 | + switch controller.outcome { | |
| 280 | + case .completed(let answer): | |
| 281 | + MarkdownView(text: answer, fontSize: fontSize) | |
| 282 | + case .failed(let reason): | |
| 283 | + RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger) | |
| 284 | + case .cancelled: | |
| 285 | + RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary) | |
| 286 | + case .stoppedByUser(let reason): | |
| 287 | + RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary) | |
| 288 | + case nil: | |
| 289 | + EmptyView() | |
| 290 | + } | |
| 291 | + } | |
| 292 | + | |
| 293 | + private var fingerprint: Int { | |
| 294 | + var value = controller.entries.count &* 13 | |
| 295 | + if case .step(let step)? = controller.entries.last { | |
| 296 | + value &+= step.text.count &+ step.invocations.count | |
| 297 | + } | |
| 298 | + if controller.outcome != nil { value &+= 1 } | |
| 299 | + return value | |
| 300 | + } | |
| 301 | +} | |
added
Sources/ZyquoAgent/Views/Settings/AdvancedSettingsTab.swift
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +// | |
| 2 | +// AdvancedSettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Advanced — reveal the data/workspaces folders, export every | |
| 9 | +// task's append-only audit log into one file, import/export tasks as JSON, | |
| 10 | +// and the menu bar extra toggle. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | +import UniformTypeIdentifiers | |
| 15 | + | |
| 16 | +struct AdvancedSettingsTab: View { | |
| 17 | + @EnvironmentObject private var store: TaskStore | |
| 18 | + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true | |
| 19 | + @State private var statusMessage: String? | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + Form { | |
| 23 | + Section("Menu bar") { | |
| 24 | + Toggle("Show Zyquo Agent in the menu bar", isOn: $menuBarExtraEnabled) | |
| 25 | + Text("The menu bar extra shows running-task status and offers New Task, Quick Task (⌥Space), and quick access to running tasks.") | |
| 26 | + .font(ZyquoFont.caption) | |
| 27 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 28 | + } | |
| 29 | + | |
| 30 | + Section("Data") { | |
| 31 | + LabeledContent("Data folder") { | |
| 32 | + Button("Reveal in Finder") { | |
| 33 | + NSWorkspace.shared.activateFileViewerSelecting([ | |
| 34 | + PersistenceService.shared.rootDirectory | |
| 35 | + ]) | |
| 36 | + } | |
| 37 | + .controlSize(.small) | |
| 38 | + } | |
| 39 | + LabeledContent("Workspaces folder") { | |
| 40 | + Button("Reveal in Finder") { | |
| 41 | + NSWorkspace.shared.activateFileViewerSelecting([ | |
| 42 | + PersistenceService.shared.workspacesDirectory | |
| 43 | + ]) | |
| 44 | + } | |
| 45 | + .controlSize(.small) | |
| 46 | + } | |
| 47 | + Text("Tasks, settings, personas, templates, and the encrypted key vault live in ~/Library/Application Support/ZyquoAgent/. The vault (vault.zq) is bound to this Mac and can't be decrypted elsewhere.") | |
| 48 | + .font(ZyquoFont.caption) | |
| 49 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 50 | + } | |
| 51 | + | |
| 52 | + Section("Audit logs") { | |
| 53 | + LabeledContent("All executed actions, across every task") { | |
| 54 | + Button("Export…") { exportAuditLogs() } | |
| 55 | + .controlSize(.small) | |
| 56 | + } | |
| 57 | + Text("Concatenates each task workspace's append-only audit.jsonl (one JSON entry per executed action) into a single export.") | |
| 58 | + .font(ZyquoFont.caption) | |
| 59 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 60 | + } | |
| 61 | + | |
| 62 | + Section("Tasks") { | |
| 63 | + HStack { | |
| 64 | + Button("Export All Tasks…") { exportTasks() } | |
| 65 | + .controlSize(.small) | |
| 66 | + Button("Import Tasks…") { importTasks() } | |
| 67 | + .controlSize(.small) | |
| 68 | + } | |
| 69 | + Text("Exports task records (titles, prompts, run histories, settings) as JSON. Workspaces are folders on disk and are not embedded.") | |
| 70 | + .font(ZyquoFont.caption) | |
| 71 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 72 | + } | |
| 73 | + | |
| 74 | + if let statusMessage { | |
| 75 | + Text(statusMessage) | |
| 76 | + .font(ZyquoFont.caption) | |
| 77 | + .foregroundStyle(ZyquoColor.success) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + .formStyle(.grouped) | |
| 81 | + } | |
| 82 | + | |
| 83 | + // MARK: - Audit export | |
| 84 | + | |
| 85 | + private func exportAuditLogs() { | |
| 86 | + let panel = NSSavePanel() | |
| 87 | + panel.allowedContentTypes = [UTType(filenameExtension: "jsonl") ?? .plainText] | |
| 88 | + panel.nameFieldStringValue = "zyquo-agent-audit.jsonl" | |
| 89 | + let tasks = store.tasks | |
| 90 | + panel.begin { response in | |
| 91 | + guard response == .OK, let url = panel.url else { return } | |
| 92 | + var lines: [String] = [] | |
| 93 | + var covered = 0 | |
| 94 | + for task in tasks { | |
| 95 | + guard let workspace = task.workspaceURL else { continue } | |
| 96 | + let auditURL = workspace | |
| 97 | + .appendingPathComponent(".zyquo") | |
| 98 | + .appendingPathComponent("audit.jsonl") | |
| 99 | + guard let content = try? String(contentsOf: auditURL, encoding: .utf8), | |
| 100 | + !content.isEmpty else { continue } | |
| 101 | + covered += 1 | |
| 102 | + lines.append(contentsOf: content.split(separator: "\n").map(String.init)) | |
| 103 | + } | |
| 104 | + try? (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) | |
| 105 | + Task { @MainActor in | |
| 106 | + statusMessage = "Exported \(lines.count) audit entr\(lines.count == 1 ? "y" : "ies") from \(covered) task\(covered == 1 ? "" : "s")." | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + // MARK: - Task import/export | |
| 112 | + | |
| 113 | + private func exportTasks() { | |
| 114 | + let panel = NSSavePanel() | |
| 115 | + panel.allowedContentTypes = [.json] | |
| 116 | + panel.nameFieldStringValue = "zyquo-agent-tasks.json" | |
| 117 | + let tasks = store.tasks | |
| 118 | + panel.begin { response in | |
| 119 | + guard response == .OK, let url = panel.url else { return } | |
| 120 | + let encoder = JSONEncoder() | |
| 121 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 122 | + encoder.dateEncodingStrategy = .iso8601 | |
| 123 | + guard let data = try? encoder.encode(tasks) else { return } | |
| 124 | + try? data.write(to: url, options: .atomic) | |
| 125 | + Task { @MainActor in | |
| 126 | + statusMessage = "Exported \(tasks.count) task\(tasks.count == 1 ? "" : "s")." | |
| 127 | + } | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + private func importTasks() { | |
| 132 | + let panel = NSOpenPanel() | |
| 133 | + panel.allowedContentTypes = [.json] | |
| 134 | + panel.allowsMultipleSelection = false | |
| 135 | + panel.begin { response in | |
| 136 | + guard response == .OK, let url = panel.url, | |
| 137 | + let data = try? Data(contentsOf: url) else { return } | |
| 138 | + let decoder = JSONDecoder() | |
| 139 | + decoder.dateDecodingStrategy = .iso8601 | |
| 140 | + guard let imported = try? decoder.decode([AgentTask].self, from: data) else { | |
| 141 | + Task { @MainActor in statusMessage = "Import failed — not a Zyquo Agent task export." } | |
| 142 | + return | |
| 143 | + } | |
| 144 | + Task { @MainActor in | |
| 145 | + let added = store.importTasks(imported) | |
| 146 | + statusMessage = "Imported \(added) task\(added == 1 ? "" : "s") (\(imported.count - added) already present)." | |
| 147 | + } | |
| 148 | + } | |
| 149 | + } | |
| 150 | +} | |
added
Sources/ZyquoAgent/Views/Settings/AgentSettingsTab.swift
+227 −0
@@ -0,0 +1,227 @@ | ||
| 1 | +// | |
| 2 | +// AgentSettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Agent — the engine tunables persisted to AgentSettings.json | |
| 9 | +// and applied to every new run (max steps, token/time budgets, per-command | |
| 10 | +// timeout, parallel tool calls, compaction threshold), plus the Personas | |
| 11 | +// CRUD (system-prompt addition + preferred model + safety default). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct AgentSettingsTab: View { | |
| 17 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 18 | + @EnvironmentObject private var personas: PersonaStore | |
| 19 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 20 | + @State private var editingPersona: Persona? | |
| 21 | + @State private var creatingPersona = false | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + Form { | |
| 25 | + Section("Run budgets (LoopGuard)") { | |
| 26 | + Stepper(value: $settings.settings.maxSteps, in: 5...200, step: 5) { | |
| 27 | + LabeledContent("Max steps per run", value: "\(settings.settings.maxSteps)") | |
| 28 | + } | |
| 29 | + Stepper(value: $settings.settings.tokenBudget, in: 50_000...5_000_000, step: 50_000) { | |
| 30 | + LabeledContent("Token budget per run", value: "\(settings.settings.tokenBudget / 1_000)K") | |
| 31 | + } | |
| 32 | + Stepper(value: $settings.settings.timeBudgetMinutes, in: 5...240, step: 5) { | |
| 33 | + LabeledContent("Time budget per run", value: "\(settings.settings.timeBudgetMinutes) min") | |
| 34 | + } | |
| 35 | + Text("When a budget trips, the run pauses and asks — it never aborts silently.") | |
| 36 | + .font(ZyquoFont.caption) | |
| 37 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 38 | + } | |
| 39 | + | |
| 40 | + Section("Execution") { | |
| 41 | + Stepper(value: $settings.settings.perCommandTimeoutSeconds, in: 10...3600, step: 10) { | |
| 42 | + LabeledContent("Per-command timeout", value: "\(settings.settings.perCommandTimeoutSeconds) s") | |
| 43 | + } | |
| 44 | + Toggle("Parallel tool calls", isOn: $settings.settings.parallelToolCalls) | |
| 45 | + Text("When on, a turn's tool calls run concurrently — but only when every call is read-only under the active policy. Mutating calls always run sequentially.") | |
| 46 | + .font(ZyquoFont.caption) | |
| 47 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 48 | + } | |
| 49 | + | |
| 50 | + Section("Memory") { | |
| 51 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 52 | + LabeledContent("Compaction threshold") { | |
| 53 | + Text("\(Int(settings.settings.compactionThreshold * 100))% of context window") | |
| 54 | + .font(ZyquoFont.caption) | |
| 55 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 56 | + .monospacedDigit() | |
| 57 | + } | |
| 58 | + Slider(value: $settings.settings.compactionThreshold, in: 0.5...0.95, step: 0.05) | |
| 59 | + } | |
| 60 | + Text("Older completed steps are summarized when estimated context usage crosses this fraction; the plan, MEMORY.md, and recent steps always survive verbatim.") | |
| 61 | + .font(ZyquoFont.caption) | |
| 62 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 63 | + } | |
| 64 | + | |
| 65 | + Section("Personas") { | |
| 66 | + if personas.personas.isEmpty { | |
| 67 | + Text("Personas add a system-prompt section to every run of a task, and can pin a preferred model and safety default.") | |
| 68 | + .font(ZyquoFont.body(size: 12)) | |
| 69 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 70 | + } | |
| 71 | + ForEach(personas.personas) { persona in | |
| 72 | + personaRow(persona) | |
| 73 | + } | |
| 74 | + Button { | |
| 75 | + creatingPersona = true | |
| 76 | + } label: { | |
| 77 | + Label("New Persona", systemImage: "plus") | |
| 78 | + } | |
| 79 | + .controlSize(.small) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + .formStyle(.grouped) | |
| 83 | + .sheet(item: $editingPersona) { persona in | |
| 84 | + PersonaEditorSheet(persona: persona) { updated in | |
| 85 | + personas.update(updated) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + .sheet(isPresented: $creatingPersona) { | |
| 89 | + PersonaEditorSheet(persona: Persona(name: "", systemPrompt: "")) { created in | |
| 90 | + personas.add(created) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + private func personaRow(_ persona: Persona) -> some View { | |
| 96 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 97 | + Image(systemName: persona.symbolName) | |
| 98 | + .font(.system(size: 12)) | |
| 99 | + .foregroundStyle(ZyquoColor.accent) | |
| 100 | + .frame(width: 18) | |
| 101 | + VStack(alignment: .leading, spacing: 1) { | |
| 102 | + Text(persona.name) | |
| 103 | + .font(ZyquoFont.bodyEmphasis(size: 12.5)) | |
| 104 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 105 | + Text(persona.systemPrompt) | |
| 106 | + .font(ZyquoFont.caption) | |
| 107 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 108 | + .lineLimit(1) | |
| 109 | + } | |
| 110 | + Spacer() | |
| 111 | + if let modelID = persona.modelID { | |
| 112 | + ZyquoBadge(text: modelID.split(separator: "/").last.map(String.init) ?? modelID) | |
| 113 | + } | |
| 114 | + if let mode = persona.safetyMode { | |
| 115 | + ZyquoBadge(text: mode.displayName, color: ZyquoColor.textSecondary) | |
| 116 | + } | |
| 117 | + Button { | |
| 118 | + editingPersona = persona | |
| 119 | + } label: { | |
| 120 | + Image(systemName: "pencil") | |
| 121 | + .font(.system(size: 11)) | |
| 122 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 123 | + } | |
| 124 | + .buttonStyle(.plain) | |
| 125 | + .help("Edit persona") | |
| 126 | + Button { | |
| 127 | + personas.delete(persona.id) | |
| 128 | + } label: { | |
| 129 | + Image(systemName: "trash") | |
| 130 | + .font(.system(size: 11)) | |
| 131 | + .foregroundStyle(ZyquoColor.danger) | |
| 132 | + } | |
| 133 | + .buttonStyle(.plain) | |
| 134 | + .help("Delete persona") | |
| 135 | + } | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +// MARK: - Persona editor | |
| 140 | + | |
| 141 | +struct PersonaEditorSheet: View { | |
| 142 | + @State var persona: Persona | |
| 143 | + var onSave: (Persona) -> Void | |
| 144 | + | |
| 145 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 146 | + @Environment(\.dismiss) private var dismiss | |
| 147 | + | |
| 148 | + /// Sentinel tag for "no preferred model" in the picker. | |
| 149 | + private static let noModelTag = "" | |
| 150 | + | |
| 151 | + var body: some View { | |
| 152 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 153 | + Text(persona.name.isEmpty ? "New Persona" : "Edit Persona") | |
| 154 | + .font(ZyquoFont.title) | |
| 155 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 156 | + Form { | |
| 157 | + TextField("Name", text: $persona.name) | |
| 158 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 159 | + Text("System-prompt addition") | |
| 160 | + .font(ZyquoFont.caption) | |
| 161 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 162 | + TextEditor(text: $persona.systemPrompt) | |
| 163 | + .font(ZyquoFont.body(size: 12.5)) | |
| 164 | + .frame(height: 110) | |
| 165 | + .padding(ZyquoSpacing.xxs) | |
| 166 | + .background( | |
| 167 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 168 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 169 | + ) | |
| 170 | + } | |
| 171 | + Picker("Preferred agent model", selection: modelSelection) { | |
| 172 | + Text("None (use default)").tag(Self.noModelTag) | |
| 173 | + ForEach(catalog.agentCapableModels) { model in | |
| 174 | + Text("\(model.displayName) — \(model.provider.displayName)") | |
| 175 | + .tag("\(model.provider.rawValue)|\(model.id)") | |
| 176 | + } | |
| 177 | + } | |
| 178 | + Picker("Default safety mode", selection: safetySelection) { | |
| 179 | + Text("App default").tag(Self.noModelTag) | |
| 180 | + ForEach(SafetyMode.allCases) { mode in | |
| 181 | + Text(mode.displayName).tag(mode.rawValue) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + HStack { | |
| 186 | + Spacer() | |
| 187 | + Button("Cancel") { dismiss() } | |
| 188 | + Button("Save") { | |
| 189 | + onSave(persona) | |
| 190 | + dismiss() | |
| 191 | + } | |
| 192 | + .buttonStyle(.borderedProminent) | |
| 193 | + .keyboardShortcut(.defaultAction) | |
| 194 | + .disabled(persona.name.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + .padding(ZyquoSpacing.xl) | |
| 198 | + .frame(width: 460) | |
| 199 | + .background(ZyquoColor.surface) | |
| 200 | + } | |
| 201 | + | |
| 202 | + private var modelSelection: Binding<String> { | |
| 203 | + Binding( | |
| 204 | + get: { | |
| 205 | + guard let provider = persona.provider, let id = persona.modelID else { return Self.noModelTag } | |
| 206 | + return "\(provider.rawValue)|\(id)" | |
| 207 | + }, | |
| 208 | + set: { key in | |
| 209 | + let parts = key.split(separator: "|", maxSplits: 1) | |
| 210 | + if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])) { | |
| 211 | + persona.provider = provider | |
| 212 | + persona.modelID = String(parts[1]) | |
| 213 | + } else { | |
| 214 | + persona.provider = nil | |
| 215 | + persona.modelID = nil | |
| 216 | + } | |
| 217 | + } | |
| 218 | + ) | |
| 219 | + } | |
| 220 | + | |
| 221 | + private var safetySelection: Binding<String> { | |
| 222 | + Binding( | |
| 223 | + get: { persona.safetyMode?.rawValue ?? Self.noModelTag }, | |
| 224 | + set: { persona.safetyMode = SafetyMode(rawValue: $0) } | |
| 225 | + ) | |
| 226 | + } | |
| 227 | +} | |
added
Sources/ZyquoAgent/Views/Settings/AppearanceSettingsTab.swift
+110 −0
@@ -0,0 +1,110 @@ | ||
| 1 | +// | |
| 2 | +// AppearanceSettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Appearance — theme mode (Light/Dark/System), the five accent | |
| 9 | +// choices with swatches (violet flagship + graphite, sky, emerald, amber), | |
| 10 | +// and the chat font size slider (12–18pt) with live preview. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct AppearanceSettingsTab: View { | |
| 16 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 17 | + | |
| 18 | + var body: some View { | |
| 19 | + Form { | |
| 20 | + Picker("Theme", selection: $appearance.themeMode) { | |
| 21 | + ForEach(ThemeMode.allCases) { mode in | |
| 22 | + Text(mode.displayName).tag(mode) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + .pickerStyle(.segmented) | |
| 26 | + | |
| 27 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 28 | + Text("Accent") | |
| 29 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 30 | + ForEach(AccentChoice.allCases) { choice in | |
| 31 | + accentSwatch(choice) | |
| 32 | + } | |
| 33 | + Spacer(minLength: 0) | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 38 | + HStack { | |
| 39 | + Text("Chat font size") | |
| 40 | + Spacer() | |
| 41 | + Text(String(format: "%.1f pt", appearance.chatFontSize)) | |
| 42 | + .font(ZyquoFont.caption) | |
| 43 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 44 | + .monospacedDigit() | |
| 45 | + } | |
| 46 | + Slider(value: $appearance.chatFontSize, in: 12...18, step: 0.5) | |
| 47 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 48 | + Text("Preview") | |
| 49 | + .font(ZyquoFont.caption) | |
| 50 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 51 | + Text("The quick brown fox jumps over the lazy dog — Zyquo Agent renders task text at this size, with generous 1.45 line height for readability.") | |
| 52 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 53 | + .lineSpacing(appearance.chatFontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 54 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 55 | + .padding(ZyquoSpacing.sm) | |
| 56 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 57 | + .background( | |
| 58 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 59 | + .fill(ZyquoColor.surface) | |
| 60 | + .overlay( | |
| 61 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 62 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 63 | + ) | |
| 64 | + ) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + .formStyle(.grouped) | |
| 69 | + } | |
| 70 | + | |
| 71 | + private func accentSwatch(_ choice: AccentChoice) -> some View { | |
| 72 | + let isSelected = appearance.accent == choice | |
| 73 | + return Button { | |
| 74 | + withAnimation(ZyquoMotion.appear) { appearance.accent = choice } | |
| 75 | + } label: { | |
| 76 | + VStack(spacing: ZyquoSpacing.xxs) { | |
| 77 | + Circle() | |
| 78 | + .fill(swatchColor(choice)) | |
| 79 | + .frame(width: 26, height: 26) | |
| 80 | + .overlay( | |
| 81 | + Circle().strokeBorder( | |
| 82 | + isSelected ? ZyquoColor.textPrimary : ZyquoColor.border, | |
| 83 | + lineWidth: isSelected ? 2 : ZyquoMetrics.hairline | |
| 84 | + ) | |
| 85 | + ) | |
| 86 | + .overlay { | |
| 87 | + if isSelected { | |
| 88 | + Image(systemName: "checkmark") | |
| 89 | + .font(.system(size: 10, weight: .bold)) | |
| 90 | + .foregroundStyle(.white) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + Text(choice.displayName) | |
| 94 | + .font(ZyquoFont.caption) | |
| 95 | + .foregroundStyle(isSelected ? ZyquoColor.textPrimary : ZyquoColor.textSecondary) | |
| 96 | + } | |
| 97 | + } | |
| 98 | + .buttonStyle(PressableButtonStyle()) | |
| 99 | + .help("\(choice.displayName) accent") | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// Appearance-resolved swatch color for one accent choice. | |
| 103 | + private func swatchColor(_ choice: AccentChoice) -> Color { | |
| 104 | + let (light, dark) = choice.accentHex | |
| 105 | + return Color(nsColor: NSColor(name: nil) { appearance in | |
| 106 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 107 | + return NSColor(hex: hex) | |
| 108 | + }) | |
| 109 | + } | |
| 110 | +} | |
added
Sources/ZyquoAgent/Views/Settings/ModelsSettingsTab.swift
+177 −0
@@ -0,0 +1,177 @@ | ||
| 1 | +// | |
| 2 | +// ModelsSettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Models — the full shared Zyquo Cloud catalog grouped by | |
| 9 | +// provider, with the Agent twist: agent-capable models carry an "Agent" | |
| 10 | +// badge, and the star sets the persisted default agent model used by new | |
| 11 | +// tasks. Per-model info: context window, capability flags, pricing. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct ModelsSettingsTab: View { | |
| 17 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 18 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 19 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 20 | + @State private var selectedProvider: ProviderID = AgentModelSupport.defaultModelProvider | |
| 21 | + @State private var refreshing = false | |
| 22 | + @State private var refreshResult: String? | |
| 23 | + | |
| 24 | + var body: some View { | |
| 25 | + VStack(spacing: 0) { | |
| 26 | + HStack { | |
| 27 | + Picker("Provider", selection: $selectedProvider) { | |
| 28 | + ForEach(ProviderID.builtIn) { provider in | |
| 29 | + Text(provider.displayName).tag(provider) | |
| 30 | + } | |
| 31 | + } | |
| 32 | + .frame(width: 240) | |
| 33 | + Spacer() | |
| 34 | + if let result = refreshResult { | |
| 35 | + Text(result) | |
| 36 | + .font(ZyquoFont.caption) | |
| 37 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 38 | + .lineLimit(1) | |
| 39 | + } | |
| 40 | + Button { | |
| 41 | + refreshModels() | |
| 42 | + } label: { | |
| 43 | + if refreshing { | |
| 44 | + ProgressView().controlSize(.small) | |
| 45 | + } else { | |
| 46 | + Label("Refresh from API", systemImage: "arrow.clockwise") | |
| 47 | + } | |
| 48 | + } | |
| 49 | + .controlSize(.small) | |
| 50 | + .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider)) | |
| 51 | + } | |
| 52 | + .padding(ZyquoSpacing.sm) | |
| 53 | + ZyquoHairline() | |
| 54 | + defaultBanner | |
| 55 | + ZyquoHairline() | |
| 56 | + modelTable | |
| 57 | + } | |
| 58 | + .background(ZyquoColor.background) | |
| 59 | + } | |
| 60 | + | |
| 61 | + private var defaultBanner: some View { | |
| 62 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 63 | + Image(systemName: "star.fill") | |
| 64 | + .font(.system(size: 10)) | |
| 65 | + .foregroundStyle(ZyquoColor.warning) | |
| 66 | + if let model = settings.defaultAgentModel(in: catalog) { | |
| 67 | + Text("Default agent model: \(model.displayName) (\(model.provider.displayName)) — new tasks start with it. Star another agent-capable model to change.") | |
| 68 | + .font(ZyquoFont.caption) | |
| 69 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 70 | + } else { | |
| 71 | + Text("No default agent model — star an agent-capable model below.") | |
| 72 | + .font(ZyquoFont.caption) | |
| 73 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 74 | + } | |
| 75 | + Spacer(minLength: 0) | |
| 76 | + } | |
| 77 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 78 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 79 | + } | |
| 80 | + | |
| 81 | + private var modelTable: some View { | |
| 82 | + ScrollView { | |
| 83 | + LazyVStack(spacing: 1) { | |
| 84 | + ForEach(catalog.models(for: selectedProvider)) { model in | |
| 85 | + modelRow(model) | |
| 86 | + } | |
| 87 | + let unknown = catalog.unknownLiveIDs(for: selectedProvider) | |
| 88 | + if !unknown.isEmpty { | |
| 89 | + Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))") | |
| 90 | + .font(ZyquoFont.caption) | |
| 91 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 92 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 93 | + .padding(ZyquoSpacing.sm) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + .padding(ZyquoSpacing.sm) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + private func modelRow(_ model: AIModel) -> some View { | |
| 101 | + let isDefault = isDefaultAgentModel(model) | |
| 102 | + return HStack(spacing: ZyquoSpacing.xs) { | |
| 103 | + Button { | |
| 104 | + settings.setDefaultAgentModel(model) | |
| 105 | + } label: { | |
| 106 | + Image(systemName: isDefault ? "star.fill" : "star") | |
| 107 | + .font(.system(size: 10)) | |
| 108 | + .foregroundStyle(isDefault ? ZyquoColor.warning : ZyquoColor.textTertiary) | |
| 109 | + } | |
| 110 | + .buttonStyle(.plain) | |
| 111 | + .disabled(!model.agentCapable) | |
| 112 | + .help(model.agentCapable ? "Set as the default agent model" : "Not agent-capable — cannot be the default") | |
| 113 | + VStack(alignment: .leading, spacing: 0) { | |
| 114 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 115 | + Text(model.displayName) | |
| 116 | + .font(ZyquoFont.body(size: 12.5)) | |
| 117 | + .foregroundStyle(model.agentCapable ? ZyquoColor.textPrimary : ZyquoColor.textSecondary) | |
| 118 | + if model.agentCapable { | |
| 119 | + ZyquoBadge(text: "Agent", color: ZyquoColor.accent) | |
| 120 | + .help("Verified for deep agentic, multi-step tool use") | |
| 121 | + } | |
| 122 | + if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.success) } | |
| 123 | + if model.isLegacy { ZyquoBadge(text: "Legacy") } | |
| 124 | + } | |
| 125 | + Text(model.id) | |
| 126 | + .font(ZyquoFont.code(size: 10)) | |
| 127 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 128 | + } | |
| 129 | + Spacer() | |
| 130 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 131 | + if model.capabilities.tools { ZyquoBadge(text: "tools") } | |
| 132 | + if model.capabilities.vision { ZyquoBadge(text: "vision") } | |
| 133 | + if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") } | |
| 134 | + } | |
| 135 | + Text(model.contextBadge) | |
| 136 | + .font(ZyquoFont.caption) | |
| 137 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 138 | + .frame(width: 64, alignment: .trailing) | |
| 139 | + Text(pricingText(model)) | |
| 140 | + .font(ZyquoFont.caption) | |
| 141 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 142 | + .frame(width: 100, alignment: .trailing) | |
| 143 | + .help("USD per 1M tokens, input / output (estimates)") | |
| 144 | + } | |
| 145 | + .padding(.vertical, 3) | |
| 146 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 147 | + .zyquoHoverHighlight() | |
| 148 | + } | |
| 149 | + | |
| 150 | + private func isDefaultAgentModel(_ model: AIModel) -> Bool { | |
| 151 | + let current = settings.defaultAgentModel(in: catalog) | |
| 152 | + return current?.id == model.id && current?.provider == model.provider | |
| 153 | + } | |
| 154 | + | |
| 155 | + private func pricingText(_ model: AIModel) -> String { | |
| 156 | + guard let pricing = model.pricing else { return "—" } | |
| 157 | + return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok) | |
| 158 | + } | |
| 159 | + | |
| 160 | + private func refreshModels() { | |
| 161 | + refreshing = true | |
| 162 | + refreshResult = nil | |
| 163 | + let provider = selectedProvider | |
| 164 | + Task { | |
| 165 | + defer { refreshing = false } | |
| 166 | + do { | |
| 167 | + let key = try vault.apiKey(for: provider) | |
| 168 | + let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key) | |
| 169 | + catalog.applyLiveListing(ids, for: provider) | |
| 170 | + let unknown = catalog.unknownLiveIDs(for: provider).count | |
| 171 | + refreshResult = "\(ids.count) live models · \(unknown) not in catalog" | |
| 172 | + } catch { | |
| 173 | + refreshResult = error.localizedDescription | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | +} | |
added
Sources/ZyquoAgent/Views/Settings/ProvidersSettingsTab.swift
+147 −0
@@ -0,0 +1,147 @@ | ||
| 1 | +// | |
| 2 | +// ProvidersSettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Providers & Keys — ported from Zyquo Cloud's vault UI: one row | |
| 9 | +// per provider (glyph, name, status dot, masked key field, Test, delete), | |
| 10 | +// extended with the environment-variable fallback indicator (keys resolved | |
| 11 | +// from the environment take precedence over the vault at run time). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct ProvidersSettingsTab: View { | |
| 17 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 18 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 19 | + @State private var draftKeys: [ProviderID: String] = [:] | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + ScrollView { | |
| 23 | + VStack(spacing: ZyquoSpacing.xs) { | |
| 24 | + Text("Keys are stored in the encrypted vault shared with Zyquo Cloud (AES-256-GCM, bound to this Mac — never the Keychain). Environment variables, when set, take precedence at run time.") | |
| 25 | + .font(ZyquoFont.caption) | |
| 26 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 27 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 28 | + ForEach(ProviderID.builtIn) { provider in | |
| 29 | + providerRow(provider) | |
| 30 | + if provider != ProviderID.builtIn.last { ZyquoHairline() } | |
| 31 | + } | |
| 32 | + } | |
| 33 | + .padding(ZyquoMetrics.contentInset) | |
| 34 | + } | |
| 35 | + .background(ZyquoColor.background) | |
| 36 | + } | |
| 37 | + | |
| 38 | + private func providerRow(_ provider: ProviderID) -> some View { | |
| 39 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 40 | + Image(systemName: provider.symbolName) | |
| 41 | + .font(.system(size: 14)) | |
| 42 | + .foregroundStyle(ZyquoColor.accent) | |
| 43 | + .frame(width: 22) | |
| 44 | + VStack(alignment: .leading, spacing: 1) { | |
| 45 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 46 | + Text(provider.displayName) | |
| 47 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 48 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 49 | + statusIndicator(provider) | |
| 50 | + if let envName = activeEnvironmentKey(provider) { | |
| 51 | + ZyquoBadge(text: "env: \(envName)", color: ZyquoColor.success) | |
| 52 | + .help("An environment variable provides this key; it takes precedence over the vault.") | |
| 53 | + } | |
| 54 | + } | |
| 55 | + statusDetail(provider) | |
| 56 | + } | |
| 57 | + Spacer() | |
| 58 | + keyField(provider) | |
| 59 | + testButton(provider) | |
| 60 | + if vault.hasKey(for: provider) { | |
| 61 | + Button { | |
| 62 | + vault.deleteKey(for: provider) | |
| 63 | + } label: { | |
| 64 | + Image(systemName: "trash") | |
| 65 | + .font(.system(size: 11)) | |
| 66 | + .foregroundStyle(ZyquoColor.danger) | |
| 67 | + } | |
| 68 | + .buttonStyle(.plain) | |
| 69 | + .help("Delete key from the vault") | |
| 70 | + } | |
| 71 | + } | |
| 72 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 73 | + } | |
| 74 | + | |
| 75 | + /// The first set environment variable providing a key for this provider. | |
| 76 | + private func activeEnvironmentKey(_ provider: ProviderID) -> String? { | |
| 77 | + let environment = ProcessInfo.processInfo.environment | |
| 78 | + return AgentCLI.environmentKeyNames(for: provider).first { | |
| 79 | + !(environment[$0] ?? "").isEmpty | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + @ViewBuilder | |
| 84 | + private func statusIndicator(_ provider: ProviderID) -> some View { | |
| 85 | + switch vault.statuses[provider] ?? .unset { | |
| 86 | + case .unset: StatusDot(status: .unset) | |
| 87 | + case .saved: StatusDot(status: .unset).overlay(Circle().strokeBorder(ZyquoColor.textSecondary, lineWidth: 1)) | |
| 88 | + case .testing: ProgressView().controlSize(.mini) | |
| 89 | + case .verified: StatusDot(status: .verified) | |
| 90 | + case .failed: StatusDot(status: .failed) | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + @ViewBuilder | |
| 95 | + private func statusDetail(_ provider: ProviderID) -> some View { | |
| 96 | + switch vault.statuses[provider] ?? .unset { | |
| 97 | + case .verified(let latency): | |
| 98 | + Text(String(format: "Verified · %.0f ms", latency * 1000)) | |
| 99 | + .font(ZyquoFont.caption) | |
| 100 | + .foregroundStyle(ZyquoColor.success) | |
| 101 | + case .failed(let message): | |
| 102 | + Text(message) | |
| 103 | + .font(ZyquoFont.caption) | |
| 104 | + .foregroundStyle(ZyquoColor.danger) | |
| 105 | + .lineLimit(1) | |
| 106 | + .help(message) | |
| 107 | + case .saved: | |
| 108 | + Text(vault.redactedKeys[provider] ?? "") | |
| 109 | + .font(ZyquoFont.caption) | |
| 110 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 111 | + default: | |
| 112 | + Text("No key") | |
| 113 | + .font(ZyquoFont.caption) | |
| 114 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + private func keyField(_ provider: ProviderID) -> some View { | |
| 119 | + SecureField( | |
| 120 | + vault.hasKey(for: provider) ? (vault.redactedKeys[provider] ?? "") : "API key", | |
| 121 | + text: Binding( | |
| 122 | + get: { draftKeys[provider] ?? "" }, | |
| 123 | + set: { draftKeys[provider] = $0 } | |
| 124 | + ) | |
| 125 | + ) | |
| 126 | + .textFieldStyle(.roundedBorder) | |
| 127 | + .font(ZyquoFont.code(size: 11)) | |
| 128 | + .frame(width: 200) | |
| 129 | + .onSubmit { saveDraft(provider) } | |
| 130 | + } | |
| 131 | + | |
| 132 | + private func testButton(_ provider: ProviderID) -> some View { | |
| 133 | + Button("Test") { | |
| 134 | + saveDraft(provider) | |
| 135 | + Task { await vault.testKey(for: provider, catalog: catalog) } | |
| 136 | + } | |
| 137 | + .controlSize(.small) | |
| 138 | + .disabled(!vault.hasKey(for: provider) && (draftKeys[provider] ?? "").isEmpty) | |
| 139 | + } | |
| 140 | + | |
| 141 | + private func saveDraft(_ provider: ProviderID) { | |
| 142 | + if let draft = draftKeys[provider], !draft.trimmingCharacters(in: .whitespaces).isEmpty { | |
| 143 | + vault.setKey(draft, for: provider) | |
| 144 | + draftKeys[provider] = "" | |
| 145 | + } | |
| 146 | + } | |
| 147 | +} | |
added
Sources/ZyquoAgent/Views/Settings/SafetySettingsTab.swift
+214 −0
@@ -0,0 +1,214 @@ | ||
| 1 | +// | |
| 2 | +// SafetySettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Safety — default safety mode, the editable allow/deny rule | |
| 9 | +// lists, the read-only built-in destructive-pattern list, the AppleScript | |
| 10 | +// approval toggle and the workspace-escape policy. | |
| 11 | +// | |
| 12 | +// The rule editor reads/writes the same policy-rules.json the PolicyEngine | |
| 13 | +// uses (through PersistenceService). Each run constructs a fresh | |
| 14 | +// PolicyEngine at task start, which loads the rules then — edits here apply | |
| 15 | +// to the NEXT run, not to a run already in flight. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import SwiftUI | |
| 19 | + | |
| 20 | +struct SafetySettingsTab: View { | |
| 21 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + Form { | |
| 25 | + Section("Defaults") { | |
| 26 | + Picker("Default safety mode for new tasks", selection: $settings.settings.defaultSafetyMode) { | |
| 27 | + ForEach(SafetyMode.allCases) { mode in | |
| 28 | + Text(mode.displayName).tag(mode) | |
| 29 | + } | |
| 30 | + } | |
| 31 | + .pickerStyle(.segmented) | |
| 32 | + Text(modeDescription) | |
| 33 | + .font(ZyquoFont.caption) | |
| 34 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 35 | + } | |
| 36 | + | |
| 37 | + Section("AppleScript & workspace boundaries") { | |
| 38 | + Toggle("Require approval for AppleScript", isOn: $settings.settings.requireApprovalForAppleScript) | |
| 39 | + Text("Manual and Guarded modes always ask before AppleScript runs. This setting is stored for the Autonomous-mode engine hook (not yet enforced there — Autonomous currently auto-runs only scripts with no risky patterns).") | |
| 40 | + .font(ZyquoFont.caption) | |
| 41 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 42 | + Picker("File access outside the workspace", selection: $settings.settings.workspaceEscapePolicy) { | |
| 43 | + ForEach(WorkspaceEscapePolicy.allCases) { policy in | |
| 44 | + Text(policy.displayName).tag(policy) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + Text("The engine currently always asks on any read or write outside the task workspace, in every mode. “Deny without asking” is stored for a future engine hook.") | |
| 48 | + .font(ZyquoFont.caption) | |
| 49 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 50 | + } | |
| 51 | + | |
| 52 | + PolicyRulesSection() | |
| 53 | + | |
| 54 | + Section("Built-in destructive patterns (always ask — every mode)") { | |
| 55 | + DisclosureGroup("Never run (hard deny)") { | |
| 56 | + ForEach(ShellCommandAnalyzer.hardDenyDescriptions, id: \.self) { item in | |
| 57 | + patternRow(item, color: ZyquoColor.danger) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + DisclosureGroup("Always require approval") { | |
| 61 | + ForEach(ShellCommandAnalyzer.alwaysAskDescriptions, id: \.self) { item in | |
| 62 | + patternRow(item, color: ZyquoColor.warning) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + Text("These circuit breakers are built in and cannot be disabled — not even by Autonomous mode or a remembered allow rule.") | |
| 66 | + .font(ZyquoFont.caption) | |
| 67 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 68 | + } | |
| 69 | + } | |
| 70 | + .formStyle(.grouped) | |
| 71 | + } | |
| 72 | + | |
| 73 | + private var modeDescription: String { | |
| 74 | + switch settings.settings.defaultSafetyMode { | |
| 75 | + case .manual: return "Manual — approve every action." | |
| 76 | + case .guarded: return "Guarded — safe/read-only actions run automatically, anything mutating asks." | |
| 77 | + case .autonomous: return "Autonomous — runs freely within budget; destructive and elevated actions still ask." | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + private func patternRow(_ text: String, color: Color) -> some View { | |
| 82 | + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xxs) { | |
| 83 | + Circle() | |
| 84 | + .fill(color) | |
| 85 | + .frame(width: 5, height: 5) | |
| 86 | + .padding(.top, 4) | |
| 87 | + Text(text) | |
| 88 | + .font(ZyquoFont.body(size: 12)) | |
| 89 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 90 | + .fixedSize(horizontal: false, vertical: true) | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +// MARK: - Allow/deny rule editor | |
| 96 | + | |
| 97 | +/// Edits the PolicyEngine's stored rules through the same policy-rules.json | |
| 98 | +/// document (PersistenceService). Live engines load rules at task start. | |
| 99 | +private struct PolicyRulesSection: View { | |
| 100 | + @State private var rules = StoredPolicyRules() | |
| 101 | + @State private var newPattern = "" | |
| 102 | + @State private var newKind = "bash" | |
| 103 | + @State private var newList: RuleList = .allow | |
| 104 | + | |
| 105 | + private enum RuleList: String, CaseIterable, Identifiable { | |
| 106 | + case allow = "Allow" | |
| 107 | + case deny = "Deny" | |
| 108 | + var id: String { rawValue } | |
| 109 | + } | |
| 110 | + | |
| 111 | + private static let rulesFileName = "policy-rules.json" | |
| 112 | + | |
| 113 | + var body: some View { | |
| 114 | + Section("Allow / deny rules") { | |
| 115 | + Text("Token-prefix rules evaluated per subcommand: “brew list” matches `brew list --versions` but not `brew install`. Deny rules always win; allow rules can never cover a built-in destructive pattern. Changes apply to the next run.") | |
| 116 | + .font(ZyquoFont.caption) | |
| 117 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 118 | + | |
| 119 | + if rules.allow.isEmpty && rules.deny.isEmpty { | |
| 120 | + Text("No rules yet — “Approve & remember” on an approval card adds allow rules here.") | |
| 121 | + .font(ZyquoFont.body(size: 12)) | |
| 122 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 123 | + } | |
| 124 | + ForEach(rules.deny, id: \.self) { rule in | |
| 125 | + ruleRow(rule, isDeny: true) | |
| 126 | + } | |
| 127 | + ForEach(rules.allow, id: \.self) { rule in | |
| 128 | + ruleRow(rule, isDeny: false) | |
| 129 | + } | |
| 130 | + | |
| 131 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 132 | + Picker("", selection: $newList) { | |
| 133 | + ForEach(RuleList.allCases) { list in Text(list.rawValue).tag(list) } | |
| 134 | + } | |
| 135 | + .labelsHidden() | |
| 136 | + .frame(width: 90) | |
| 137 | + Picker("", selection: $newKind) { | |
| 138 | + Text("bash").tag("bash") | |
| 139 | + Text("osascript").tag("osascript") | |
| 140 | + } | |
| 141 | + .labelsHidden() | |
| 142 | + .frame(width: 110) | |
| 143 | + TextField("Pattern (e.g. brew list)", text: $newPattern) | |
| 144 | + .textFieldStyle(.roundedBorder) | |
| 145 | + .font(ZyquoFont.code(size: 11)) | |
| 146 | + .onSubmit(addRule) | |
| 147 | + Button("Add") { addRule() } | |
| 148 | + .controlSize(.small) | |
| 149 | + .disabled(newPattern.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + .onAppear(perform: load) | |
| 153 | + } | |
| 154 | + | |
| 155 | + private func ruleRow(_ rule: StoredPolicyRule, isDeny: Bool) -> some View { | |
| 156 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 157 | + ZyquoBadge( | |
| 158 | + text: isDeny ? "deny" : "allow", | |
| 159 | + color: isDeny ? ZyquoColor.danger : ZyquoColor.success | |
| 160 | + ) | |
| 161 | + Text(rule.kind) | |
| 162 | + .font(ZyquoFont.code(size: 11)) | |
| 163 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 164 | + .frame(width: 70, alignment: .leading) | |
| 165 | + Text(rule.pattern) | |
| 166 | + .font(ZyquoFont.code(size: 11.5)) | |
| 167 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 168 | + Spacer() | |
| 169 | + Button { | |
| 170 | + remove(rule, fromDeny: isDeny) | |
| 171 | + } label: { | |
| 172 | + Image(systemName: "trash") | |
| 173 | + .font(.system(size: 10)) | |
| 174 | + .foregroundStyle(ZyquoColor.danger) | |
| 175 | + } | |
| 176 | + .buttonStyle(.plain) | |
| 177 | + .help("Remove rule") | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + // MARK: Persistence (same document the PolicyEngine loads per task start) | |
| 182 | + | |
| 183 | + private func load() { | |
| 184 | + rules = PersistenceService.shared.load(StoredPolicyRules.self, from: Self.rulesFileName) | |
| 185 | + ?? StoredPolicyRules() | |
| 186 | + } | |
| 187 | + | |
| 188 | + private func save() { | |
| 189 | + PersistenceService.shared.save(rules, to: Self.rulesFileName) | |
| 190 | + } | |
| 191 | + | |
| 192 | + private func addRule() { | |
| 193 | + let pattern = newPattern.trimmingCharacters(in: .whitespaces) | |
| 194 | + guard !pattern.isEmpty else { return } | |
| 195 | + let rule = StoredPolicyRule(kind: newKind, pattern: pattern) | |
| 196 | + switch newList { | |
| 197 | + case .allow: | |
| 198 | + if !rules.allow.contains(rule) { rules.allow.append(rule) } | |
| 199 | + case .deny: | |
| 200 | + if !rules.deny.contains(rule) { rules.deny.append(rule) } | |
| 201 | + } | |
| 202 | + newPattern = "" | |
| 203 | + save() | |
| 204 | + } | |
| 205 | + | |
| 206 | + private func remove(_ rule: StoredPolicyRule, fromDeny: Bool) { | |
| 207 | + if fromDeny { | |
| 208 | + rules.deny.removeAll { $0 == rule } | |
| 209 | + } else { | |
| 210 | + rules.allow.removeAll { $0 == rule } | |
| 211 | + } | |
| 212 | + save() | |
| 213 | + } | |
| 214 | +} | |
added
Sources/ZyquoAgent/Views/Settings/SettingsView.swift
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +// | |
| 2 | +// SettingsView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings window (760×560, native toolbar-style tabs): Providers & Keys, | |
| 9 | +// Models, Safety, Agent, Appearance, Shortcuts, Advanced — same structure | |
| 10 | +// and DNA as Zyquo Cloud's Settings, extended with the Agent tabs. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import AppKit | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct SettingsView: View { | |
| 17 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + TabView { | |
| 21 | + ProvidersSettingsTab() | |
| 22 | + .tabItem { Label("Providers & Keys", systemImage: "key") } | |
| 23 | + ModelsSettingsTab() | |
| 24 | + .tabItem { Label("Models", systemImage: "cpu") } | |
| 25 | + SafetySettingsTab() | |
| 26 | + .tabItem { Label("Safety", systemImage: "shield.lefthalf.filled") } | |
| 27 | + AgentSettingsTab() | |
| 28 | + .tabItem { Label("Agent", systemImage: "gearshape.arrow.triangle.2.circlepath") } | |
| 29 | + AppearanceSettingsTab() | |
| 30 | + .tabItem { Label("Appearance", systemImage: "paintbrush") } | |
| 31 | + ShortcutsSettingsTab() | |
| 32 | + .tabItem { Label("Shortcuts", systemImage: "keyboard") } | |
| 33 | + AdvancedSettingsTab() | |
| 34 | + .tabItem { Label("Advanced", systemImage: "gearshape.2") } | |
| 35 | + } | |
| 36 | + .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight) | |
| 37 | + .preferredColorScheme(appearance.themeMode.colorScheme) | |
| 38 | + .tint(appearance.accentColor) | |
| 39 | + .id(appearance.accent) | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// Opens the SwiftUI Settings scene from anywhere (gear buttons, palette). | |
| 44 | +/// macOS 14+ exposes `showSettingsWindow:`; macOS 13 kept the old | |
| 45 | +/// `showPreferencesWindow:` selector — try both. | |
| 46 | +@MainActor | |
| 47 | +enum SettingsOpener { | |
| 48 | + static func open() { | |
| 49 | + NSApp.activate(ignoringOtherApps: true) | |
| 50 | + let selectors = ["showSettingsWindow:", "showPreferencesWindow:"] | |
| 51 | + for name in selectors { | |
| 52 | + let selector = Selector(name) | |
| 53 | + if NSApp.responds(to: selector) || NSApp.sendAction(selector, to: nil, from: nil) { | |
| 54 | + NSApp.sendAction(selector, to: nil, from: nil) | |
| 55 | + return | |
| 56 | + } | |
| 57 | + } | |
| 58 | + } | |
| 59 | +} | |
added
Sources/ZyquoAgent/Views/Settings/ShortcutsSettingsTab.swift
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +// | |
| 2 | +// ShortcutsSettingsTab.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings › Shortcuts — the read-only shortcut reference for the full | |
| 9 | +// Phase 6 command set. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct ShortcutsSettingsTab: View { | |
| 15 | + private static let shortcuts: [(String, String)] = [ | |
| 16 | + ("New task", "⌘N"), | |
| 17 | + ("Command palette (templates · tasks · actions)", "⌘K"), | |
| 18 | + ("Run task", "⌘↩"), | |
| 19 | + ("Stop run", "⌘."), | |
| 20 | + ("Search tasks", "⌘F"), | |
| 21 | + ("Open audit log", "⌘⇧A"), | |
| 22 | + ("Quick Task panel", "⌥Space"), | |
| 23 | + ("Export transcript (Markdown)", "⌘⇧E"), | |
| 24 | + ("Settings", "⌘,"), | |
| 25 | + ] | |
| 26 | + | |
| 27 | + var body: some View { | |
| 28 | + Form { | |
| 29 | + ForEach(Self.shortcuts, id: \.0) { name, keys in | |
| 30 | + LabeledContent(name) { | |
| 31 | + Text(keys) | |
| 32 | + .font(ZyquoFont.code(size: 12)) | |
| 33 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 34 | + } | |
| 35 | + } | |
| 36 | + } | |
| 37 | + .formStyle(.grouped) | |
| 38 | + } | |
| 39 | +} | |
deleted
Sources/ZyquoAgent/Views/SettingsPlaceholderView.swift
+0 −47
@@ -1,47 +0,0 @@ | ||
| 1 | −// | |
| 2 | −// SettingsPlaceholderView.swift | |
| 3 | −// Zyquo Agent | |
| 4 | −// | |
| 5 | −// Author: Simon-Pierre Boucher | |
| 6 | −// Mail: contact@spboucher.ai | |
| 7 | −// | |
| 8 | −// Wave-1 stand-in for the full Settings window (Providers & Keys, Models, | |
| 9 | −// Safety, Agent, Appearance, Shortcuts, Advanced arrive in wave 2). Until | |
| 10 | −// then, keys can be added via the encrypted vault CLI or environment | |
| 11 | −// variables — this sheet says so plainly instead of dead-ending the user. | |
| 12 | −// | |
| 13 | − | |
| 14 | −import SwiftUI | |
| 15 | − | |
| 16 | −struct SettingsPlaceholderView: View { | |
| 17 | − @Environment(\.dismiss) private var dismiss | |
| 18 | − | |
| 19 | − var body: some View { | |
| 20 | − VStack(alignment: .leading, spacing: ZyquoSpacing.md) { | |
| 21 | − HStack(spacing: ZyquoSpacing.xs) { | |
| 22 | − Image(systemName: "gearshape") | |
| 23 | − .font(.system(size: 16)) | |
| 24 | − .foregroundStyle(ZyquoColor.accent) | |
| 25 | − Text("Settings") | |
| 26 | − .font(ZyquoFont.title) | |
| 27 | − .foregroundStyle(ZyquoColor.textPrimary) | |
| 28 | − } | |
| 29 | − Text("The full Settings window — Providers & Keys, Models, Safety rules, Agent budgets, Appearance, and Shortcuts — arrives in the next update.") | |
| 30 | − .font(ZyquoFont.body()) | |
| 31 | − .foregroundStyle(ZyquoColor.textSecondary) | |
| 32 | − .fixedSize(horizontal: false, vertical: true) | |
| 33 | − Text("Until then, Zyquo Agent reads API keys from your environment (for example ANTHROPIC_API_KEY or OPENAI_API_KEY) or from the encrypted vault shared with Zyquo Cloud.") | |
| 34 | − .font(ZyquoFont.body()) | |
| 35 | − .foregroundStyle(ZyquoColor.textSecondary) | |
| 36 | − .fixedSize(horizontal: false, vertical: true) | |
| 37 | − HStack { | |
| 38 | − Spacer() | |
| 39 | − Button("OK") { dismiss() } | |
| 40 | − .keyboardShortcut(.defaultAction) | |
| 41 | − } | |
| 42 | − } | |
| 43 | − .padding(ZyquoSpacing.xl) | |
| 44 | − .frame(width: ZyquoMetrics.quickTaskWidth - ZyquoSpacing.xxl * 4) | |
| 45 | − .background(ZyquoColor.surface) | |
| 46 | − } | |
| 47 | −} | |
modified
Sources/ZyquoAgent/Views/Sidebar/SidebarView.swift
+10 −9
@@ -18,8 +18,8 @@ struct SidebarView: View { | ||
| 18 | 18 | @EnvironmentObject private var store: TaskStore |
| 19 | 19 | @EnvironmentObject private var hub: RunHub |
| 20 | 20 | @EnvironmentObject private var catalog: ModelCatalog |
| 21 | + @EnvironmentObject private var settings: AgentSettingsStore | |
| 21 | 22 | @FocusState private var searchFocused: Bool |
| 22 | − @State private var showingSettingsPlaceholder = false | |
| 23 | 23 | |
| 24 | 24 | var body: some View { |
| 25 | 25 | VStack(spacing: 0) { |
@@ -37,9 +37,6 @@ struct SidebarView: View { | ||
| 37 | 37 | .keyboardShortcut("f", modifiers: .command) |
| 38 | 38 | .hidden() |
| 39 | 39 | ) |
| 40 | − .sheet(isPresented: $showingSettingsPlaceholder) { | |
| 41 | − SettingsPlaceholderView() | |
| 42 | − } | |
| 43 | 40 | } |
| 44 | 41 | |
| 45 | 42 | // MARK: - Sections |
@@ -79,7 +76,10 @@ struct SidebarView: View { | ||
| 79 | 76 | |
| 80 | 77 | private var newTaskButton: some View { |
| 81 | 78 | Button { |
| 82 | − store.newTask(model: catalog.defaultAgentModel) | |
| 79 | + store.newTask( | |
| 80 | + model: settings.defaultAgentModel(in: catalog), | |
| 81 | + safetyMode: settings.settings.defaultSafetyMode | |
| 82 | + ) | |
| 83 | 83 | } label: { |
| 84 | 84 | HStack(spacing: ZyquoSpacing.xxs) { |
| 85 | 85 | Image(systemName: "plus.circle.fill") |
@@ -124,14 +124,14 @@ struct SidebarView: View { | ||
| 124 | 124 | private var footer: some View { |
| 125 | 125 | HStack(spacing: ZyquoSpacing.xs) { |
| 126 | 126 | Button { |
| 127 | − showingSettingsPlaceholder = true | |
| 127 | + SettingsOpener.open() | |
| 128 | 128 | } label: { |
| 129 | 129 | Image(systemName: "gearshape") |
| 130 | 130 | .font(.system(size: 13)) |
| 131 | 131 | .foregroundStyle(ZyquoColor.textSecondary) |
| 132 | 132 | } |
| 133 | 133 | .buttonStyle(.plain) |
| 134 | − .help("Settings") | |
| 134 | + .help("Settings (⌘,)") | |
| 135 | 135 | ZyquoBadge(text: footerSafetyMode.displayName, color: ZyquoColor.textSecondary) |
| 136 | 136 | Spacer(minLength: 0) |
| 137 | 137 | if let model = footerModel { |
@@ -145,7 +145,8 @@ struct SidebarView: View { | ||
| 145 | 145 | |
| 146 | 146 | /// Safety mode of the selected task (or the app default). |
| 147 | 147 | private var footerSafetyMode: SafetyMode { |
| 148 | − store.selectedID.flatMap { store.task(id: $0)?.safetyMode } ?? .guarded | |
| 148 | + store.selectedID.flatMap { store.task(id: $0)?.safetyMode } | |
| 149 | + ?? settings.settings.defaultSafetyMode | |
| 149 | 150 | } |
| 150 | 151 | |
| 151 | 152 | /// Model of the selected task (or the default agent model). |
@@ -154,7 +155,7 @@ struct SidebarView: View { | ||
| 154 | 155 | let model = catalog.model(id: task.modelID, provider: task.providerID) { |
| 155 | 156 | return model |
| 156 | 157 | } |
| 157 | − return catalog.defaultAgentModel | |
| 158 | + return settings.defaultAgentModel(in: catalog) | |
| 158 | 159 | } |
| 159 | 160 | } |
| 160 | 161 | |
modified
Sources/ZyquoAgent/Views/TaskDetailView.swift
+48 −58
@@ -32,12 +32,13 @@ struct TaskDetailContent: View { | ||
| 32 | 32 | @EnvironmentObject private var catalog: ModelCatalog |
| 33 | 33 | @EnvironmentObject private var vault: KeyVaultStore |
| 34 | 34 | @EnvironmentObject private var appearance: AppearanceStore |
| 35 | + @EnvironmentObject private var personas: PersonaStore | |
| 36 | + @EnvironmentObject private var uiState: AppUIState | |
| 35 | 37 | |
| 36 | 38 | @State private var draft = "" |
| 37 | 39 | @State private var editingTitle = false |
| 38 | 40 | @State private var titleDraft = "" |
| 39 | 41 | @State private var showingInfo = false |
| 40 | − @State private var showingSettingsPlaceholder = false | |
| 41 | 42 | @State private var planVisible = true |
| 42 | 43 | @State private var drawerVisible = false |
| 43 | 44 | |
@@ -92,9 +93,6 @@ struct TaskDetailContent: View { | ||
| 92 | 93 | .keyboardShortcut(".", modifiers: .command) |
| 93 | 94 | .hidden() |
| 94 | 95 | ) |
| 95 | − .sheet(isPresented: $showingSettingsPlaceholder) { | |
| 96 | − SettingsPlaceholderView() | |
| 97 | − } | |
| 98 | 96 | .onAppear { |
| 99 | 97 | if let pending = store.pendingDraft { |
| 100 | 98 | draft = pending |
@@ -104,6 +102,16 @@ struct TaskDetailContent: View { | ||
| 104 | 102 | .onChange(of: controller.isRunning) { running in |
| 105 | 103 | if running { drawerVisible = true } |
| 106 | 104 | } |
| 105 | + // App-level command signals (⌘⇧A, palette actions). | |
| 106 | + .onChange(of: uiState.auditLogRequest) { _ in | |
| 107 | + withAnimation(ZyquoMotion.appear) { drawerVisible = true } | |
| 108 | + } | |
| 109 | + .onChange(of: uiState.drawerToggleRequest) { _ in | |
| 110 | + withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } | |
| 111 | + } | |
| 112 | + .onChange(of: uiState.planToggleRequest) { _ in | |
| 113 | + withAnimation(ZyquoMotion.appear) { planVisible.toggle() } | |
| 114 | + } | |
| 107 | 115 | } |
| 108 | 116 | |
| 109 | 117 | // MARK: - Header (52pt) |
@@ -164,15 +172,22 @@ struct TaskDetailContent: View { | ||
| 164 | 172 | active: drawerVisible, |
| 165 | 173 | help: "Toggle activity drawer" |
| 166 | 174 | ) { withAnimation(ZyquoMotion.appear) { drawerVisible.toggle() } } |
| 167 | − Button { | |
| 168 | − exportTranscript() | |
| 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 | + } | |
| 169 | 182 | } label: { |
| 170 | 183 | Image(systemName: "square.and.arrow.up") |
| 171 | 184 | .font(.system(size: 12)) |
| 172 | 185 | .foregroundStyle(ZyquoColor.textSecondary) |
| 173 | 186 | } |
| 174 | − .buttonStyle(.plain) | |
| 175 | − .help("Export task transcript as Markdown") | |
| 187 | + .menuStyle(.borderlessButton) | |
| 188 | + .menuIndicator(.hidden) | |
| 189 | + .fixedSize() | |
| 190 | + .help("Export task transcript (Markdown / PDF)") | |
| 176 | 191 | Button { |
| 177 | 192 | showingInfo.toggle() |
| 178 | 193 | } label: { |
@@ -268,12 +283,31 @@ struct TaskDetailContent: View { | ||
| 268 | 283 | AgentEmptyStateView( |
| 269 | 284 | model: currentModel, |
| 270 | 285 | safetyMode: task.safetyMode, |
| 286 | + personaName: personas.persona(id: task.personaID)?.name, | |
| 271 | 287 | onSelectModel: select(model:), |
| 272 | 288 | onSelectSafetyMode: { controller.setSafetyMode($0) }, |
| 289 | + onSelectPersona: { persona in select(persona: persona) }, | |
| 290 | + onBrowseTemplates: { uiState.showTemplateBrowser = true }, | |
| 273 | 291 | onSuggestion: { draft = $0 } |
| 274 | 292 | ) |
| 275 | 293 | } |
| 276 | 294 | |
| 295 | + /// Adopts a persona for this task: stores the id and applies its | |
| 296 | + /// preferred model / safety default when set. | |
| 297 | + private func select(persona: Persona?) { | |
| 298 | + guard var task else { return } | |
| 299 | + task.personaID = persona?.id | |
| 300 | + if let persona, let id = persona.modelID, let provider = persona.provider, | |
| 301 | + let preferred = catalog.model(id: id, provider: provider) { | |
| 302 | + task.modelID = preferred.id | |
| 303 | + task.providerID = preferred.provider | |
| 304 | + } | |
| 305 | + store.update(task, touch: false) | |
| 306 | + if let mode = persona?.safetyMode { | |
| 307 | + controller.setSafetyMode(mode) | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 277 | 311 | private var noKeyForModel: Bool { |
| 278 | 312 | guard let model = currentModel else { return false } |
| 279 | 313 | return AgentCLI.resolveAPIKey(for: model.provider) == nil |
@@ -286,7 +320,7 @@ struct TaskDetailContent: View { | ||
| 286 | 320 | Text("No API key for \(currentModel?.provider.displayName ?? "this provider") yet.") |
| 287 | 321 | .font(ZyquoFont.body(size: 12.5)) |
| 288 | 322 | Button("Providers & Keys…") { |
| 289 | − showingSettingsPlaceholder = true | |
| 323 | + SettingsOpener.open() | |
| 290 | 324 | } |
| 291 | 325 | .font(ZyquoFont.body(size: 12.5)) |
| 292 | 326 | Spacer(minLength: 0) |
@@ -318,7 +352,11 @@ struct TaskDetailContent: View { | ||
| 318 | 352 | guard let model = currentModel else { return } |
| 319 | 353 | let prompt = draft |
| 320 | 354 | withAnimation(ZyquoMotion.appear) { draft = "" } |
| 321 | − controller.start(prompt: prompt, model: model) | |
| 355 | + controller.start( | |
| 356 | + prompt: prompt, | |
| 357 | + model: model, | |
| 358 | + persona: personas.persona(id: task?.personaID) | |
| 359 | + ) | |
| 322 | 360 | } |
| 323 | 361 | |
| 324 | 362 | private func select(model: AIModel) { |
@@ -328,52 +366,4 @@ struct TaskDetailContent: View { | ||
| 328 | 366 | store.update(task, touch: false) |
| 329 | 367 | } |
| 330 | 368 | |
| 331 | − /// Exports the task's history (prompts, steps, answers) as Markdown. | |
| 332 | − private func exportTranscript() { | |
| 333 | − guard let task else { return } | |
| 334 | − let panel = NSSavePanel() | |
| 335 | − panel.allowedContentTypes = [.plainText] | |
| 336 | − panel.nameFieldStringValue = "\(WorkspaceManager.slug(from: task.title)).md" | |
| 337 | − panel.begin { response in | |
| 338 | − guard response == .OK, let url = panel.url else { return } | |
| 339 | − let markdown = Self.markdown(for: task) | |
| 340 | − try? markdown.write(to: url, atomically: true, encoding: .utf8) | |
| 341 | − } | |
| 342 | − } | |
| 343 | − | |
| 344 | − private static func markdown(for task: AgentTask) -> String { | |
| 345 | − var lines: [String] = ["# \(task.title)", ""] | |
| 346 | − lines.append("Model: `\(task.modelID)` (\(task.providerID.displayName)) · Safety: \(task.safetyMode.displayName)") | |
| 347 | − if let workspace = task.workspacePath { | |
| 348 | − lines.append("Workspace: `\(workspace)`") | |
| 349 | − } | |
| 350 | − lines.append("") | |
| 351 | − for message in task.messages { | |
| 352 | − switch message.kind { | |
| 353 | − case .user: | |
| 354 | − lines.append("## 🧑 Prompt") | |
| 355 | − lines.append(message.text) | |
| 356 | − case .agentRun: | |
| 357 | − lines.append("## 🤖 Run") | |
| 358 | − for step in message.steps ?? [] { | |
| 359 | − lines.append("### Step \(step.index)") | |
| 360 | − if !step.text.isEmpty { lines.append(step.text) } | |
| 361 | − for invocation in step.toolInvocations { | |
| 362 | − lines.append("```") | |
| 363 | − lines.append("\(invocation.call.name): \(invocation.call.argumentsJSON)") | |
| 364 | − if let result = invocation.result { | |
| 365 | − lines.append("→ \(result.content)") | |
| 366 | − } | |
| 367 | − lines.append("```") | |
| 368 | − } | |
| 369 | − } | |
| 370 | − if !message.text.isEmpty { | |
| 371 | − lines.append("### Result") | |
| 372 | − lines.append(message.text) | |
| 373 | − } | |
| 374 | − } | |
| 375 | − lines.append("") | |
| 376 | − } | |
| 377 | − return lines.joined(separator: "\n") | |
| 378 | − } | |
| 379 | 369 | } |
added
Sources/ZyquoAgent/Views/Templates/TemplateBrowserView.swift
+323 −0
@@ -0,0 +1,323 @@ | ||
| 1 | +// | |
| 2 | +// TemplateBrowserView.swift | |
| 3 | +// Zyquo Agent | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The template browser sheet (empty state "Browse templates", ⌘K, menu): | |
| 9 | +// built-in library + user templates grouped by category, searchable, with | |
| 10 | +// user-template CRUD. Using a template with {{variables}} opens the fill-in | |
| 11 | +// sheet; the rendered prompt lands in a new task's input bar (pendingDraft). | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct TemplateBrowserView: View { | |
| 17 | + /// Called with the rendered prompt and the template's suggested mode. | |
| 18 | + var onUse: (String, SafetyMode) -> Void | |
| 19 | + | |
| 20 | + @EnvironmentObject private var templates: TemplateStore | |
| 21 | + @Environment(\.dismiss) private var dismiss | |
| 22 | + @State private var query = "" | |
| 23 | + @State private var fillingTemplate: TaskTemplate? | |
| 24 | + @State private var editingTemplate: TaskTemplate? | |
| 25 | + @State private var creatingTemplate = false | |
| 26 | + | |
| 27 | + var body: some View { | |
| 28 | + VStack(spacing: 0) { | |
| 29 | + header | |
| 30 | + ZyquoHairline() | |
| 31 | + ScrollView { | |
| 32 | + LazyVStack(alignment: .leading, spacing: 1) { | |
| 33 | + ForEach(TemplateCategory.allCases) { category in | |
| 34 | + let matching = filtered(in: category) | |
| 35 | + if !matching.isEmpty { | |
| 36 | + sectionHeader(category) | |
| 37 | + ForEach(matching) { template in | |
| 38 | + templateRow(template) | |
| 39 | + } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + } | |
| 43 | + .padding(ZyquoSpacing.xs) | |
| 44 | + } | |
| 45 | + ZyquoHairline() | |
| 46 | + footer | |
| 47 | + } | |
| 48 | + .frame(width: 560, height: 480) | |
| 49 | + .background(ZyquoColor.surface) | |
| 50 | + .sheet(item: $fillingTemplate) { template in | |
| 51 | + TemplateFillSheet(template: template) { prompt in | |
| 52 | + use(prompt: prompt, template: template) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + .sheet(item: $editingTemplate) { template in | |
| 56 | + TemplateEditorSheet(template: template, isNew: false) | |
| 57 | + } | |
| 58 | + .sheet(isPresented: $creatingTemplate) { | |
| 59 | + TemplateEditorSheet( | |
| 60 | + template: TaskTemplate(title: "", category: .filesAndFolders, prompt: ""), | |
| 61 | + isNew: true | |
| 62 | + ) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + private var header: some View { | |
| 67 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 68 | + Image(systemName: "square.grid.2x2") | |
| 69 | + .font(.system(size: 14)) | |
| 70 | + .foregroundStyle(ZyquoColor.accent) | |
| 71 | + Text("Task Templates") | |
| 72 | + .font(ZyquoFont.bodyEmphasis(size: 14)) | |
| 73 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 74 | + Spacer() | |
| 75 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 76 | + Image(systemName: "magnifyingglass") | |
| 77 | + .font(.system(size: 11)) | |
| 78 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 79 | + TextField("Search templates", text: $query) | |
| 80 | + .textFieldStyle(.plain) | |
| 81 | + .font(ZyquoFont.body(size: 12.5)) | |
| 82 | + .frame(width: 160) | |
| 83 | + } | |
| 84 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 85 | + .padding(.vertical, 4) | |
| 86 | + .background( | |
| 87 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 88 | + .fill(ZyquoColor.surfaceSecondary) | |
| 89 | + ) | |
| 90 | + } | |
| 91 | + .padding(ZyquoSpacing.sm) | |
| 92 | + } | |
| 93 | + | |
| 94 | + private var footer: some View { | |
| 95 | + HStack { | |
| 96 | + Button { | |
| 97 | + creatingTemplate = true | |
| 98 | + } label: { | |
| 99 | + Label("New Template", systemImage: "plus") | |
| 100 | + } | |
| 101 | + .controlSize(.small) | |
| 102 | + Spacer() | |
| 103 | + Button("Close") { dismiss() } | |
| 104 | + .controlSize(.small) | |
| 105 | + .keyboardShortcut(.cancelAction) | |
| 106 | + } | |
| 107 | + .padding(ZyquoSpacing.sm) | |
| 108 | + } | |
| 109 | + | |
| 110 | + private func sectionHeader(_ category: TemplateCategory) -> some View { | |
| 111 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 112 | + Image(systemName: category.symbolName) | |
| 113 | + .font(.system(size: 10)) | |
| 114 | + Text(category.displayName) | |
| 115 | + .font(ZyquoFont.caption) | |
| 116 | + } | |
| 117 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 118 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 119 | + .padding(.top, ZyquoSpacing.sm) | |
| 120 | + .padding(.bottom, 2) | |
| 121 | + } | |
| 122 | + | |
| 123 | + private func templateRow(_ template: TaskTemplate) -> some View { | |
| 124 | + Button { | |
| 125 | + if template.variables.isEmpty { | |
| 126 | + use(prompt: template.prompt, template: template) | |
| 127 | + } else { | |
| 128 | + fillingTemplate = template | |
| 129 | + } | |
| 130 | + } label: { | |
| 131 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 132 | + Image(systemName: template.displaySymbol) | |
| 133 | + .font(.system(size: 12)) | |
| 134 | + .foregroundStyle(ZyquoColor.accent) | |
| 135 | + .frame(width: 18) | |
| 136 | + VStack(alignment: .leading, spacing: 1) { | |
| 137 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 138 | + Text(template.title) | |
| 139 | + .font(ZyquoFont.body(size: 13)) | |
| 140 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 141 | + if !template.isBuiltIn { | |
| 142 | + ZyquoBadge(text: "yours", color: ZyquoColor.success) | |
| 143 | + } | |
| 144 | + if !template.variables.isEmpty { | |
| 145 | + ZyquoBadge(text: "\(template.variables.count) variable\(template.variables.count == 1 ? "" : "s")") | |
| 146 | + } | |
| 147 | + } | |
| 148 | + Text(template.prompt) | |
| 149 | + .font(ZyquoFont.caption) | |
| 150 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 151 | + .lineLimit(1) | |
| 152 | + } | |
| 153 | + Spacer(minLength: 0) | |
| 154 | + ZyquoBadge(text: template.suggestedSafetyMode.displayName, color: ZyquoColor.textSecondary) | |
| 155 | + if !template.isBuiltIn { | |
| 156 | + Button { | |
| 157 | + editingTemplate = template | |
| 158 | + } label: { | |
| 159 | + Image(systemName: "pencil") | |
| 160 | + .font(.system(size: 10)) | |
| 161 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 162 | + } | |
| 163 | + .buttonStyle(.plain) | |
| 164 | + .help("Edit template") | |
| 165 | + } | |
| 166 | + } | |
| 167 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 168 | + .padding(.vertical, 5) | |
| 169 | + .contentShape(Rectangle()) | |
| 170 | + } | |
| 171 | + .buttonStyle(.plain) | |
| 172 | + .zyquoHoverHighlight() | |
| 173 | + } | |
| 174 | + | |
| 175 | + private func filtered(in category: TemplateCategory) -> [TaskTemplate] { | |
| 176 | + let all = templates.templates(in: category) | |
| 177 | + let trimmed = query.trimmingCharacters(in: .whitespaces) | |
| 178 | + guard !trimmed.isEmpty else { return all } | |
| 179 | + return all.filter { | |
| 180 | + $0.title.localizedCaseInsensitiveContains(trimmed) | |
| 181 | + || $0.prompt.localizedCaseInsensitiveContains(trimmed) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + private func use(prompt: String, template: TaskTemplate) { | |
| 186 | + dismiss() | |
| 187 | + onUse(prompt, template.suggestedSafetyMode) | |
| 188 | + } | |
| 189 | +} | |
| 190 | + | |
| 191 | +// MARK: - Variable fill-in | |
| 192 | + | |
| 193 | +struct TemplateFillSheet: View { | |
| 194 | + let template: TaskTemplate | |
| 195 | + var onSubmit: (String) -> Void | |
| 196 | + | |
| 197 | + @Environment(\.dismiss) private var dismiss | |
| 198 | + @State private var values: [String: String] = [:] | |
| 199 | + | |
| 200 | + var body: some View { | |
| 201 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 202 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 203 | + Image(systemName: template.displaySymbol) | |
| 204 | + .font(.system(size: 14)) | |
| 205 | + .foregroundStyle(ZyquoColor.accent) | |
| 206 | + Text(template.title) | |
| 207 | + .font(ZyquoFont.title) | |
| 208 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 209 | + } | |
| 210 | + Form { | |
| 211 | + ForEach(template.variables, id: \.self) { name in | |
| 212 | + TextField(name.capitalized, text: Binding( | |
| 213 | + get: { values[name] ?? "" }, | |
| 214 | + set: { values[name] = $0 } | |
| 215 | + )) | |
| 216 | + } | |
| 217 | + } | |
| 218 | + Text(preview) | |
| 219 | + .font(ZyquoFont.body(size: 12)) | |
| 220 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 221 | + .lineLimit(5) | |
| 222 | + .padding(ZyquoSpacing.xs) | |
| 223 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 224 | + .background( | |
| 225 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 226 | + .fill(ZyquoColor.surfaceSecondary) | |
| 227 | + ) | |
| 228 | + HStack { | |
| 229 | + Spacer() | |
| 230 | + Button("Cancel") { dismiss() } | |
| 231 | + Button("Use Template") { | |
| 232 | + dismiss() | |
| 233 | + onSubmit(template.renderedPrompt(values: values)) | |
| 234 | + } | |
| 235 | + .buttonStyle(.borderedProminent) | |
| 236 | + .keyboardShortcut(.defaultAction) | |
| 237 | + .disabled(!allFilled) | |
| 238 | + } | |
| 239 | + } | |
| 240 | + .padding(ZyquoSpacing.xl) | |
| 241 | + .frame(width: 440) | |
| 242 | + .background(ZyquoColor.surface) | |
| 243 | + } | |
| 244 | + | |
| 245 | + private var allFilled: Bool { | |
| 246 | + template.variables.allSatisfy { | |
| 247 | + !(values[$0] ?? "").trimmingCharacters(in: .whitespaces).isEmpty | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + private var preview: String { | |
| 252 | + template.renderedPrompt(values: values) | |
| 253 | + } | |
| 254 | +} | |
| 255 | + | |
| 256 | +// MARK: - User-template editor | |
| 257 | + | |
| 258 | +struct TemplateEditorSheet: View { | |
| 259 | + @State var template: TaskTemplate | |
| 260 | + let isNew: Bool | |
| 261 | + | |
| 262 | + @EnvironmentObject private var templates: TemplateStore | |
| 263 | + @Environment(\.dismiss) private var dismiss | |
| 264 | + | |
| 265 | + var body: some View { | |
| 266 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 267 | + Text(isNew ? "New Template" : "Edit Template") | |
| 268 | + .font(ZyquoFont.title) | |
| 269 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 270 | + Form { | |
| 271 | + TextField("Title", text: $template.title) | |
| 272 | + Picker("Category", selection: $template.category) { | |
| 273 | + ForEach(TemplateCategory.allCases) { category in | |
| 274 | + Text(category.displayName).tag(category) | |
| 275 | + } | |
| 276 | + } | |
| 277 | + Picker("Suggested safety mode", selection: $template.suggestedSafetyMode) { | |
| 278 | + ForEach(SafetyMode.allCases) { mode in | |
| 279 | + Text(mode.displayName).tag(mode) | |
| 280 | + } | |
| 281 | + } | |
| 282 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 283 | + Text("Prompt — use {{variable}} for fill-in placeholders") | |
| 284 | + .font(ZyquoFont.caption) | |
| 285 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 286 | + TextEditor(text: $template.prompt) | |
| 287 | + .font(ZyquoFont.body(size: 12.5)) | |
| 288 | + .frame(height: 120) | |
| 289 | + .padding(ZyquoSpacing.xxs) | |
| 290 | + .background( | |
| 291 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 292 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 293 | + ) | |
| 294 | + } | |
| 295 | + } | |
| 296 | + HStack { | |
| 297 | + if !isNew { | |
| 298 | + Button("Delete", role: .destructive) { | |
| 299 | + templates.delete(template.id) | |
| 300 | + dismiss() | |
| 301 | + } | |
| 302 | + } | |
| 303 | + Spacer() | |
| 304 | + Button("Cancel") { dismiss() } | |
| 305 | + Button("Save") { | |
| 306 | + if isNew { | |
| 307 | + templates.add(template) | |
| 308 | + } else { | |
| 309 | + templates.update(template) | |
| 310 | + } | |
| 311 | + dismiss() | |
| 312 | + } | |
| 313 | + .buttonStyle(.borderedProminent) | |
| 314 | + .keyboardShortcut(.defaultAction) | |
| 315 | + .disabled(template.title.trimmingCharacters(in: .whitespaces).isEmpty | |
| 316 | + || template.prompt.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 317 | + } | |
| 318 | + } | |
| 319 | + .padding(ZyquoSpacing.xl) | |
| 320 | + .frame(width: 480) | |
| 321 | + .background(ZyquoColor.surface) | |
| 322 | + } | |
| 323 | +} | |
modified
Sources/ZyquoAgent/Views/TerminalDrawerView.swift
+5 −0
@@ -18,6 +18,7 @@ import SwiftUI | ||
| 18 | 18 | |
| 19 | 19 | struct TerminalDrawerView: View { |
| 20 | 20 | @ObservedObject var controller: RunController |
| 21 | + @EnvironmentObject private var uiState: AppUIState | |
| 21 | 22 | |
| 22 | 23 | enum Tab: String, CaseIterable, Identifiable { |
| 23 | 24 | case live = "Live" |
@@ -48,6 +49,10 @@ struct TerminalDrawerView: View { | ||
| 48 | 49 | case .live: break |
| 49 | 50 | } |
| 50 | 51 | } |
| 52 | + // ⌘⇧A / palette: land on the Audit Log tab. | |
| 53 | + .onChange(of: uiState.auditLogRequest) { _ in | |
| 54 | + withAnimation(ZyquoMotion.picker) { tab = .audit } | |
| 55 | + } | |
| 51 | 56 | .sheet(item: $previewedFile) { entry in |
| 52 | 57 | FilePreviewSheet(entry: entry, workspaceRoot: controller.workspaceRoot) |
| 53 | 58 | } |
| 54 | 59 | |